Compare commits
2
Commits
main
..
2919282a5c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2919282a5c | ||
|
|
8b5587cf88 |
@@ -1,34 +1,21 @@
|
|||||||
# spring-messaging-demo
|
# spring-messaging-demo
|
||||||
|
|
||||||
Companion code for the messaging series on [ankurm.com](https://ankurm.com). Each directory is a
|
Companion code for the messaging series on [ankurm.com](https://ankurm.com). Each directory is a
|
||||||
self-contained Maven project for one article, with its own `pom.xml` and its own captured
|
self-contained Maven project for one article, with its own `pom.xml`, its own numbered
|
||||||
output, regenerated from the test suite rather than typed by hand. Most modules keep that
|
documentation chapters, and its own captured output under `docs/output/` — regenerated by that
|
||||||
output under `docs/output/` alongside numbered documentation chapters; `saga/` and `share-groups/` keep it
|
module's `scripts/run-all.sh`, never typed by hand.
|
||||||
directly under `output/` instead, with the equivalent depth in the article's own accordion sections.
|
|
||||||
|
|
||||||
| Module | Article | What it demonstrates |
|
| Module | Article | What it demonstrates |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| [`kafka-basics/`](kafka-basics/README.md) | [Spring Boot 4.1 and Apache Kafka: Producer, Consumer and Serialisation from Scratch](https://ankurm.com/spring-boot-4-1-kafka-producer-consumer-serialisation/) | The on-ramp: what the starter gives you, the two Jackson serializer families, where a key lands and why, and which defaults are Kafka's rather than Spring's |
|
| [`kafka-basics/`](kafka-basics/README.md) | [Spring Boot 4.1 and Apache Kafka: Producer, Consumer and Serialisation from Scratch](https://ankurm.com/spring-boot-4-1-kafka-producer-consumer-serialisation/) | The on-ramp: what the starter gives you, the two Jackson serializer families, where a key lands and why, and which defaults are Kafka's rather than Spring's |
|
||||||
| [`kafka-error-handling/`](kafka-error-handling/README.md) | [Kafka Error Handling with Spring Kafka 4.1: DLT, Retry Topics and Poison Pills](https://ankurm.com/spring-kafka-4-1-error-handling-dlt-retry-topics/) | What the default error handler really does, why a poison pill stops a partition, and what blocking retries cost that retry topics do not |
|
| [`kafka-error-handling/`](kafka-error-handling/README.md) | [Kafka Error Handling with Spring Kafka 4.1: DLT, Retry Topics and Poison Pills](https://ankurm.com/spring-kafka-4-1-error-handling-dlt-retry-topics/) | What the default error handler really does, why a poison pill stops a partition, and what blocking retries cost that retry topics do not |
|
||||||
| [`rabbitmq/`](rabbitmq/README.md) | [Spring Boot and RabbitMQ: Exchanges, Queues, Bindings and a Working Dead-Letter Queue](https://ankurm.com/spring-boot-rabbitmq-exchanges-dead-letter-queue/) | All four exchange types against a real broker, manual acknowledgement, and a dead-letter path exercised through both rejection and TTL expiry |
|
| [`rabbitmq/`](rabbitmq/README.md) | [Spring Boot and RabbitMQ: Exchanges, Queues, Bindings and a Working Dead-Letter Queue](https://ankurm.com/spring-boot-rabbitmq-exchanges-dead-letter-queue/) | All four exchange types against a real broker, manual acknowledgement, and a dead-letter path exercised through both rejection and TTL expiry |
|
||||||
| [`sse-websocket/`](sse-websocket/README.md) | [Server-Sent Events and WebSocket on Spring Boot 4: SseEmitter, STOMP, and Which to Pick](https://ankurm.com/spring-boot-4-sse-websocket-stomp/) | The two browser-facing options side by side: the SSE lifecycle and the three ways a stream ends, STOMP's routing defaults, and the size limit that is enforced by Tomcat rather than by Spring |
|
|
||||||
| [`protocol-comparison/`](protocol-comparison/README.md) | [RSocket vs gRPC vs WebSocket on Spring Boot 4.1: When Each One Wins](https://ankurm.com/rsocket-vs-grpc-vs-websocket-spring-boot-4-1/) | One application serving the same two operations over all three, benchmarked in one JVM, ending in the only measurement that transfers off the box: what each server produces for a consumer that asked for a hundred |
|
|
||||||
| [`broker-comparison/`](broker-comparison/README.md) | [Kafka vs RabbitMQ vs Pulsar for Java Teams: A Decision Framework with Benchmarks](https://ankurm.com/kafka-vs-rabbitmq-vs-pulsar-java-decision-framework/) | The three brokers measured side by side on ordering, replay, consumer scaling and operational footprint, ending in a decision table where every row has a transcript behind it |
|
|
||||||
| [`saga/`](saga/README.md) | Saga Pattern in Spring Boot: Orchestration vs Choreography with Kafka | The same order/payment/inventory saga built twice against a real broker — choreography and orchestration — driven through the same inventory failure so the compensating transaction can be compared side by side, plus a real duplicate-delivery test for the idempotent-consumer problem |
|
|
||||||
| [`share-groups/`](share-groups/README.md) | [Queues for Kafka: Share Groups with Spring for Apache Kafka 4](https://ankurm.com/kafka-share-groups-spring-kafka-4-queues-for-kafka/) | Share groups against a real Kafka 4.3.1 broker: six consumers on two partitions, the acquire mode that decides whether they really share, the three acknowledgement types, a group that starts at the end of the topic, and the ordering you give up |
|
|
||||||
| [`kafka-streams/`](kafka-streams/README.md) | Kafka Streams with Spring Boot 4: Real-Time Fraud Detection with Vector Similarity | A fraud topology tested entirely under `TopologyTestDriver`: cosine-similarity scoring, a 5-minute windowed count that alerts exactly once, and a KTable blocklist join with a tombstone, plus the Spring wiring that needs no broker to load |
|
|
||||||
|
|
||||||
The brokers make an instructive set. Kafka's consumer holds an offset and the broker remembers
|
The two brokers make an instructive pair. Kafka's consumer holds an offset and the broker
|
||||||
nothing about individual records; RabbitMQ's broker owns the message until it is acknowledged and
|
remembers nothing about individual records; RabbitMQ's broker owns the message until it is
|
||||||
can route, expire and dead-letter it on its own; Pulsar keeps the log but lets each subscriber
|
acknowledged and can route, expire and dead-letter it on its own. Almost every difference in how
|
||||||
choose how it is read. Almost every difference in how you handle failure follows from those three
|
you handle failure follows from that one sentence — which is why Kafka needs a retry topic
|
||||||
sentences — which is why Kafka needs a retry topic to do what RabbitMQ does with a queue
|
to do what RabbitMQ does with a queue argument.
|
||||||
argument, and why `broker-comparison` is mostly an argument about where a message lives.
|
|
||||||
|
|
||||||
The last two modules are about the *other* kind of messaging — a connection rather than a
|
|
||||||
broker — and they are deliberately adjacent to the broker modules, because the question
|
|
||||||
"should this be a topic or a stream?" is answered by whether anything needs to survive a dropped
|
|
||||||
connection. Nothing in `sse-websocket` or `protocol-comparison` persists a single message.
|
|
||||||
|
|
||||||
They are meant to be read in order. `kafka-basics` establishes that the default acknowledgement
|
They are meant to be read in order. `kafka-basics` establishes that the default acknowledgement
|
||||||
mode is `BATCH` and therefore that delivery is at-least-once; everything the error-handling
|
mode is `BATCH` and therefore that delivery is at-least-once; everything the error-handling
|
||||||
@@ -42,8 +29,8 @@ Central and from Boot's own `spring-boot-dependencies` POM, rather than from rel
|
|||||||
announcements.
|
announcements.
|
||||||
|
|
||||||
Every module runs its broker without Docker, so the transcripts can be regenerated on any
|
Every module runs its broker without Docker, so the transcripts can be regenerated on any
|
||||||
machine with a JDK: Kafka via the in-process KRaft broker in `spring-kafka-test`, RabbitMQ and
|
machine with a JDK: Kafka via the in-process KRaft broker in `spring-kafka-test`, RabbitMQ via a
|
||||||
Pulsar via real brokers started by the modules' own scripts. Each module also ships a Testcontainers
|
real broker started by the module's own scripts. Each module also ships a Testcontainers
|
||||||
configuration for the cases where the deployed image is what you need to test against.
|
configuration for the cases where the deployed image is what you need to test against.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
# broker-comparison
|
|
||||||
|
|
||||||
Companion project for **Kafka vs RabbitMQ vs Pulsar for Java Teams: A Decision Framework with
|
|
||||||
Benchmarks** on [ankurm.com](https://ankurm.com).
|
|
||||||
|
|
||||||
Three brokers, three questions, one set of measurements. Ordering, replay and consumer scaling are
|
|
||||||
asked identically of all three by driving the client libraries directly, so that what is compared
|
|
||||||
is the brokers' delivery models rather than three sets of Spring defaults. What each Spring
|
|
||||||
integration adds on top is [chapter 6](docs/06-what-spring-adds.md).
|
|
||||||
|
|
||||||
## Verified stack
|
|
||||||
|
|
||||||
| Component | Version | Source of the number |
|
|
||||||
|---|---|---|
|
|
||||||
| JDK (tests) | 25.0.4.1+1 (Temurin) | `java -version` |
|
|
||||||
| JDK (brokers) | 21.0.12.1+1 (Temurin) | `java -version` |
|
|
||||||
| Spring Boot | 4.1.1 | `maven-metadata.xml` on Maven Central |
|
|
||||||
| kafka-clients | 4.2.1 | `spring-boot-dependencies-4.1.1.pom` |
|
|
||||||
| amqp-client | 5.30.0 | `spring-boot-dependencies-4.1.1.pom` |
|
|
||||||
| pulsar-client | 4.2.4 | `spring-boot-dependencies-4.1.1.pom` |
|
|
||||||
| Kafka broker | 4.2.1 (KRaft) | `kafka_2.13-4.2.1.tgz` |
|
|
||||||
| RabbitMQ broker | 3.10.25 | generic-unix tarball |
|
|
||||||
| Pulsar broker | 4.2.4 standalone | `apache-pulsar-4.2.4-bin.tar.gz` |
|
|
||||||
|
|
||||||
RabbitMQ is pinned at 3.10.25 because the box that produced these transcripts has Erlang 24;
|
|
||||||
3.11 and later need Erlang 25. The AMQP 0-9-1 semantics measured here are unchanged in 4.x.
|
|
||||||
|
|
||||||
## Quickstart
|
|
||||||
|
|
||||||
The Kafka measurements need no external broker — `spring-kafka-test` starts a real KRaft broker
|
|
||||||
in-process. The other two need a broker each, and the scripts start both without Docker and
|
|
||||||
without root.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mvn -Dgroups=kafka test
|
|
||||||
|
|
||||||
ERL_ROOT=... RABBITMQ_HOME=... scripts/rabbit-broker.sh
|
|
||||||
mvn -Dgroups=rabbit test
|
|
||||||
|
|
||||||
PULSAR_HOME=... scripts/pulsar-broker.sh
|
|
||||||
mvn -Dgroups=pulsar test
|
|
||||||
|
|
||||||
scripts/footprint.sh kafka|rabbit|pulsar # the operational numbers
|
|
||||||
scripts/run-all.sh # regenerates every docs/output/ file
|
|
||||||
```
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
| Chapter | What it settles |
|
|
||||||
|---|---|
|
|
||||||
| [01 Three models](docs/01-three-models.md) | Where a message lives, and why everything else follows |
|
|
||||||
| [02 Ordering](docs/02-ordering.md) | What survives the second consumer, in all three |
|
|
||||||
| [03 Replay](docs/03-replay.md) | Reading it twice, and the one broker that cannot |
|
|
||||||
| [04 Consumer scaling](docs/04-consumer-scaling.md) | The partition ceiling, and the client buffer that defeats fan-out in the other two |
|
|
||||||
| [05 Operational footprint](docs/05-operational-footprint.md) | Startup, memory, ports, processes, configuration surface |
|
|
||||||
| [06 What Spring adds](docs/06-what-spring-adds.md) | The three integrations, and the Boot 4 starter trap |
|
|
||||||
| [07 The decision table](docs/07-the-decision-table.md) | Every row backed by a measurement, and how to choose |
|
|
||||||
|
|
||||||
## Captured output
|
|
||||||
|
|
||||||
| File | What it shows |
|
|
||||||
|---|---|
|
|
||||||
| [`kafka-ordering.txt`](docs/output/kafka-ordering.txt) | Per-key order kept, global order not — and why A, B and C were the wrong keys to demonstrate it |
|
|
||||||
| [`kafka-replay.txt`](docs/output/kafka-replay.txt) | The same 12 records read three times |
|
|
||||||
| [`kafka-consumer-scaling.txt`](docs/output/kafka-consumer-scaling.txt) | 5 consumers, 3 partitions, 2 idle |
|
|
||||||
| [`rabbit-ordering.txt`](docs/output/rabbit-ordering.txt) | Message 1 finishing tenth |
|
|
||||||
| [`rabbit-replay.txt`](docs/output/rabbit-replay.txt) | 12 messages, then 0, then an empty queue |
|
|
||||||
| [`rabbit-consumer-scaling.txt`](docs/output/rabbit-consumer-scaling.txt) | 40/0/0/0/0 without `basicQos`, 8 each with it |
|
|
||||||
| [`pulsar-ordering.txt`](docs/output/pulsar-ordering.txt) | `Shared` spreading a key, `Key_Shared` pinning it |
|
|
||||||
| [`pulsar-replay.txt`](docs/output/pulsar-replay.txt) | `seek(earliest)` and a new subscription |
|
|
||||||
| [`pulsar-consumer-scaling.txt`](docs/output/pulsar-consumer-scaling.txt) | The 1000-message receiver queue defeating fan-out |
|
|
||||||
| [`footprint.txt`](docs/output/footprint.txt) | Startup, memory, ports and config surface for all three |
|
|
||||||
| [`tests.txt`](docs/output/tests.txt) | The nine tests behind all of the above |
|
|
||||||
|
|
||||||
## What is not measured here
|
|
||||||
|
|
||||||
Throughput. A messages-per-second number from one 2-core container says nothing about any of
|
|
||||||
these brokers, and publishing one would be worse than publishing nothing. The measurements above
|
|
||||||
are structural: they hold on any hardware, because they are properties of the delivery models
|
|
||||||
rather than of the machine.
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
[README](../README.md) · next: [Ordering](02-ordering.md)
|
|
||||||
|
|
||||||
# 1. Three storage models, and everything that follows from them
|
|
||||||
|
|
||||||
Almost every difference between these brokers follows from one sentence about where a message
|
|
||||||
lives.
|
|
||||||
|
|
||||||
- **Kafka** is an append-only log, partitioned. The broker keeps every record for the retention
|
|
||||||
period and remembers nothing about individual consumers except an offset. Reading does not
|
|
||||||
remove anything.
|
|
||||||
- **RabbitMQ** is a router with queues. The broker owns each message until a consumer
|
|
||||||
acknowledges it, and then deletes it. It can route, expire, and dead-letter on its own.
|
|
||||||
- **Pulsar** is a log too, but the cursor and the storage are separate services: brokers are
|
|
||||||
stateless and BookKeeper holds the data. Acknowledgement moves a cursor, and a message is
|
|
||||||
deleted once every subscription has passed it — unless a retention policy says otherwise.
|
|
||||||
|
|
||||||
Read those three sentences again before reading any comparison table, including the one in
|
|
||||||
[chapter 7](07-the-decision-table.md). They predict most of it.
|
|
||||||
|
|
||||||
| | Kafka | RabbitMQ | Pulsar |
|
|
||||||
|---|---|---|---|
|
|
||||||
| unit of parallelism | partition | queue | subscription |
|
|
||||||
| set when | topic is created | queue is declared | consumer subscribes |
|
|
||||||
| consuming | moves an offset | deletes the message | moves a cursor |
|
|
||||||
| a second reader | new consumer group | another queue, bound in advance | new subscription |
|
|
||||||
|
|
||||||
The last row is the one people underestimate. In Kafka and Pulsar you can add a reader that sees
|
|
||||||
history you have already processed. In RabbitMQ you cannot add one after the fact at all — the
|
|
||||||
messages are gone — so the decision to have a second consumer has to be made *before* the
|
|
||||||
messages arrive.
|
|
||||||
|
|
||||||
## What was measured, and how
|
|
||||||
|
|
||||||
Three questions, asked identically of all three brokers by driving the client libraries directly
|
|
||||||
rather than through three different Spring abstractions:
|
|
||||||
|
|
||||||
| Question | Chapter |
|
|
||||||
|---|---|
|
|
||||||
| What ordering survives when you add a second consumer? | [02](02-ordering.md) |
|
|
||||||
| Can you read the same message twice? | [03](03-replay.md) |
|
|
||||||
| How far do consumers scale, and what stops them? | [04](04-consumer-scaling.md) |
|
|
||||||
| What does each one cost to run? | [05](05-operational-footprint.md) |
|
|
||||||
|
|
||||||
Versions are the ones a Spring Boot 4.1.1 application gets: kafka-clients 4.2.1, amqp-client
|
|
||||||
5.30.0, pulsar-client 4.2.4, all read from `spring-boot-dependencies-4.1.1.pom`. The brokers are
|
|
||||||
Kafka 4.2.1, RabbitMQ 3.10.25 and Pulsar 4.2.4, each started from its own distribution by a script
|
|
||||||
in `scripts/`.
|
|
||||||
|
|
||||||
next: [Ordering](02-ordering.md)
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
prev: [Three models](01-three-models.md) · [README](../README.md) · next: [Replay](03-replay.md)
|
|
||||||
|
|
||||||
# 2. Ordering
|
|
||||||
|
|
||||||
Every broker here is FIFO with one producer and one consumer. The question that matters is what
|
|
||||||
survives the second consumer, because that is the first thing you add.
|
|
||||||
|
|
||||||
## Kafka: ordered within a partition, and a key picks the partition
|
|
||||||
|
|
||||||
Twelve records, three keys, three partitions
|
|
||||||
([`docs/output/kafka-ordering.txt`](output/kafka-ordering.txt)):
|
|
||||||
|
|
||||||
```
|
|
||||||
F -> partition [2] values [2, 5, 8, 11]
|
|
||||||
A -> partition [1] values [1, 4, 7, 10]
|
|
||||||
D -> partition [0] values [3, 6, 9, 12]
|
|
||||||
```
|
|
||||||
|
|
||||||
Each key's values come back in the order they were produced. The global sequence does not: the
|
|
||||||
consumer drains one partition's buffer before moving to the next, so the delivered order is
|
|
||||||
2, 5, 8, 11, 1, 4, 7, 10, 3, 6, 9, 12.
|
|
||||||
|
|
||||||
That is the whole Kafka ordering guarantee, and it is usually enough, because "in order" nearly
|
|
||||||
always means "in order per customer / per account / per device" rather than globally.
|
|
||||||
|
|
||||||
**A detail worth stealing.** The keys in that transcript are D, A and F, not A, B and C. With
|
|
||||||
three partitions, murmur2 sends A, B *and* C all to partition 1. Picking three obvious keys to
|
|
||||||
demonstrate partitioning would have produced a transcript in which global order was accidentally
|
|
||||||
preserved. Before you conclude that your keys spread evenly, compute
|
|
||||||
`Utils.toPositive(Utils.murmur2(key)) % partitions` for the ones you actually use.
|
|
||||||
|
|
||||||
## RabbitMQ: FIFO per queue, and no key at all
|
|
||||||
|
|
||||||
One consumer sees the queue in order. Two consumers do not
|
|
||||||
([`docs/output/rabbit-ordering.txt`](output/rabbit-ordering.txt)):
|
|
||||||
|
|
||||||
```
|
|
||||||
one queue, one consumer : [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
|
|
||||||
one queue, two consumers : [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 1, 12]
|
|
||||||
```
|
|
||||||
|
|
||||||
Message 1 went to the slower consumer and finished tenth. There is no key and no partition, so
|
|
||||||
there is nothing to pin related messages to one consumer. Ordering across related messages in
|
|
||||||
RabbitMQ means one queue and one consumer — which means no horizontal scaling of that queue —
|
|
||||||
or the consistent-hash exchange, which is a plugin and effectively reintroduces partitions by
|
|
||||||
hand.
|
|
||||||
|
|
||||||
## Pulsar: the subscription type decides, per subscriber
|
|
||||||
|
|
||||||
Two consumers on a `Shared` subscription and two on `Key_Shared`, same topic, same messages
|
|
||||||
([`docs/output/pulsar-ordering.txt`](output/pulsar-ordering.txt)):
|
|
||||||
|
|
||||||
```
|
|
||||||
Shared A -> [consumer-1, consumer-2] B -> [consumer-1, consumer-2] C -> [consumer-1, consumer-2]
|
|
||||||
Key_Shared A -> [consumer-2] B -> [consumer-1] C -> [consumer-2]
|
|
||||||
```
|
|
||||||
|
|
||||||
Under `Shared`, every key was handled by both consumers, so two messages with the same key can be
|
|
||||||
in flight at once and no ordering claim survives. Under `Key_Shared`, each key went to exactly one
|
|
||||||
consumer — Kafka's guarantee, but chosen by the subscriber rather than fixed by a partition count
|
|
||||||
someone picked when the topic was created, and recomputed as consumers join and leave.
|
|
||||||
|
|
||||||
That last property is the strongest single argument for Pulsar: two teams reading the same topic
|
|
||||||
can make different ordering-versus-throughput trades without negotiating.
|
|
||||||
|
|
||||||
next: [Replay](03-replay.md)
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
prev: [Ordering](02-ordering.md) · [README](../README.md) · next: [Consumer scaling](04-consumer-scaling.md)
|
|
||||||
|
|
||||||
# 3. Replay
|
|
||||||
|
|
||||||
"Can I read that again?" is asked after a bad deploy, and by then it is too late to change
|
|
||||||
brokers.
|
|
||||||
|
|
||||||
## Kafka
|
|
||||||
|
|
||||||
```
|
|
||||||
group replay-group-1, first read : 12 records
|
|
||||||
group replay-group-2, brand new group : 12 records
|
|
||||||
group replay-group-1, after seekToBeginning: 12 records
|
|
||||||
```
|
|
||||||
|
|
||||||
([`docs/output/kafka-replay.txt`](output/kafka-replay.txt)) Consuming moves an offset and removes
|
|
||||||
nothing. A new group, a reset offset and a `seekToBeginning` all read the same records, for as
|
|
||||||
long as retention keeps them.
|
|
||||||
|
|
||||||
## RabbitMQ
|
|
||||||
|
|
||||||
```
|
|
||||||
first drain of the queue : 12 messages
|
|
||||||
second drain of the queue : 0 messages
|
|
||||||
queue depth afterwards : 0
|
|
||||||
```
|
|
||||||
|
|
||||||
([`docs/output/rabbit-replay.txt`](output/rabbit-replay.txt)) Acknowledging deletes. There is no
|
|
||||||
offset to rewind and no second reader that can see what the first consumed. Reading a message
|
|
||||||
twice has to be arranged in advance — a second queue bound to the same exchange, or a copy written
|
|
||||||
somewhere else — and it cannot be arranged afterwards.
|
|
||||||
|
|
||||||
This is not a defect. A router that deletes what it has delivered is much cheaper to operate than
|
|
||||||
a log, and most work queues genuinely do not need history. It only becomes a defect at the moment
|
|
||||||
you need history and do not have it.
|
|
||||||
|
|
||||||
RabbitMQ streams (3.9+) are a separate, log-shaped feature that does support replay. They are a
|
|
||||||
different thing living in the same broker, with their own client protocol and their own semantics
|
|
||||||
— worth knowing about, and not what you get from `queueDeclare`.
|
|
||||||
|
|
||||||
## Pulsar
|
|
||||||
|
|
||||||
```
|
|
||||||
subscription replay-sub, first read : 12 messages
|
|
||||||
subscription replay-sub, after seek(earliest): 12 messages
|
|
||||||
subscription replay-sub-2, brand new : 12 messages
|
|
||||||
```
|
|
||||||
|
|
||||||
([`docs/output/pulsar-replay.txt`](output/pulsar-replay.txt)) `seek(MessageId)` and
|
|
||||||
`seek(timestamp)` rewind a live subscription; a new subscription starting from `Earliest` reads
|
|
||||||
everything still stored.
|
|
||||||
|
|
||||||
The difference from Kafka is the **default**, and it is the one that surprises people: Kafka keeps
|
|
||||||
a record for the retention period regardless of who read it, while Pulsar deletes a message once
|
|
||||||
every subscription has acknowledged it, unless a retention policy on the namespace says otherwise.
|
|
||||||
A Pulsar namespace with the default retention and one well-behaved subscription keeps nothing —
|
|
||||||
so the replay you are counting on requires a policy you have to set.
|
|
||||||
|
|
||||||
next: [Consumer scaling](04-consumer-scaling.md)
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
prev: [Replay](03-replay.md) · [README](../README.md) · next: [Operational footprint](05-operational-footprint.md)
|
|
||||||
|
|
||||||
# 4. Consumer scaling
|
|
||||||
|
|
||||||
The question is what happens when you add the fifth consumer.
|
|
||||||
|
|
||||||
## Kafka: partitions are a hard ceiling
|
|
||||||
|
|
||||||
Five consumers in one group on a three-partition topic
|
|
||||||
([`docs/output/kafka-consumer-scaling.txt`](output/kafka-consumer-scaling.txt)):
|
|
||||||
|
|
||||||
```
|
|
||||||
consumer-1 -> partitions [0]
|
|
||||||
consumer-2 -> partitions [1]
|
|
||||||
consumer-3 -> partitions [2]
|
|
||||||
consumer-4 -> no partitions (idle)
|
|
||||||
consumer-5 -> no partitions (idle)
|
|
||||||
```
|
|
||||||
|
|
||||||
A partition belongs to at most one consumer in a group, so partition count is the ceiling. Two of
|
|
||||||
the five processes are running, connected, healthy, and doing nothing at all — and they will keep
|
|
||||||
reporting healthy forever.
|
|
||||||
|
|
||||||
Raising the partition count later is possible and is not free: it changes which partition a key
|
|
||||||
maps to, so the per-key ordering guarantee is broken across the change for every key that moves.
|
|
||||||
In practice the partition count is a capacity decision made at design time, on incomplete
|
|
||||||
information, that you then live with.
|
|
||||||
|
|
||||||
## RabbitMQ: no ceiling, but prefetch decides whether it is real
|
|
||||||
|
|
||||||
Five consumers on one queue, forty messages
|
|
||||||
([`docs/output/rabbit-consumer-scaling.txt`](output/rabbit-consumer-scaling.txt)):
|
|
||||||
|
|
||||||
```
|
|
||||||
no basicQos at all (unlimited prefetch, the AMQP default):
|
|
||||||
consumer-1 -> 40 consumer-2..5 -> 0
|
|
||||||
|
|
||||||
basicQos(1):
|
|
||||||
consumer-1..5 -> 8 each
|
|
||||||
```
|
|
||||||
|
|
||||||
There is no structural ceiling — but with the AMQP default the broker pushes as many messages as
|
|
||||||
a consumer will accept, so the first consumer to connect can be handed the entire backlog while
|
|
||||||
four idle processes wait. `basicQos` is not a tuning knob to postpone; it is what makes the
|
|
||||||
fan-out exist.
|
|
||||||
|
|
||||||
Spring AMQP sets it for you: `AbstractMessageListenerContainer.DEFAULT_PREFETCH_COUNT` is 250.
|
|
||||||
Better than unlimited, and still enough to concentrate any backlog smaller than 250 messages on
|
|
||||||
one consumer.
|
|
||||||
|
|
||||||
## Pulsar: no ceiling either, and the same trap under a different name
|
|
||||||
|
|
||||||
Five consumers, one *non-partitioned* topic, forty messages
|
|
||||||
([`docs/output/pulsar-consumer-scaling.txt`](output/pulsar-consumer-scaling.txt)):
|
|
||||||
|
|
||||||
```
|
|
||||||
receiverQueueSize default (1000):
|
|
||||||
one or two consumers take everything; the rest receive nothing
|
|
||||||
|
|
||||||
receiverQueueSize(1):
|
|
||||||
consumer-1..5 -> 8 each
|
|
||||||
```
|
|
||||||
|
|
||||||
The topic has no partitions and five consumers still share the work, which in Kafka would require
|
|
||||||
at least five partitions decided in advance. But the default receiver queue is a thousand
|
|
||||||
messages, so the first consumers to connect pull the whole backlog into their own buffers first.
|
|
||||||
Same failure as RabbitMQ's unlimited prefetch, four times the default.
|
|
||||||
|
|
||||||
## The shape of the answer
|
|
||||||
|
|
||||||
- Kafka gives you ordering per key and pays for it with a fixed parallelism ceiling.
|
|
||||||
- RabbitMQ gives you unlimited competing consumers and pays for it with no ordering across them.
|
|
||||||
- Pulsar lets each subscription choose, which is genuinely the best of both — at the operational
|
|
||||||
cost measured in [the next chapter](05-operational-footprint.md).
|
|
||||||
|
|
||||||
And all three brokers have a client-side buffer that will quietly defeat your fan-out if you leave
|
|
||||||
it at the default and your backlog is small. That is the one finding here that applies to
|
|
||||||
whichever you pick.
|
|
||||||
|
|
||||||
next: [Operational footprint](05-operational-footprint.md)
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
prev: [Consumer scaling](04-consumer-scaling.md) · [README](../README.md) · next: [What Spring adds](06-what-spring-adds.md)
|
|
||||||
|
|
||||||
# 5. Operational footprint
|
|
||||||
|
|
||||||
"Ops burden" cannot be benchmarked. The number of moving parts, the memory floor and the size of
|
|
||||||
the configuration surface can be, and they are what people are actually asking about.
|
|
||||||
|
|
||||||
Each broker started from its shipped distribution with default configuration and a 700 MB heap
|
|
||||||
cap, on one 2-core / 3.8 GB Linux box, Temurin JDK 21
|
|
||||||
([`docs/output/footprint.txt`](output/footprint.txt)):
|
|
||||||
|
|
||||||
| | Kafka 4.2.1 | RabbitMQ 3.10.25 | Pulsar 4.2.4 standalone |
|
|
||||||
|---|---|---|---|
|
|
||||||
| launch to first accepted connection | 10.0 s | 15.1 s | 20.8 s |
|
|
||||||
| resident memory at idle | 333 MB | 115 MB | 610 MB |
|
|
||||||
| server processes | 1 | 1 (+ `epmd`) | 1 |
|
|
||||||
| listening ports | 9092, 9093 | 5672, 4369, 25672 | 6650, 8080, 2181 |
|
|
||||||
| unpacked distribution | 135 MB | 26 MB | 344 MB |
|
|
||||||
| settings in the shipped default config | 24 | **0** | 357 |
|
|
||||||
|
|
||||||
Read those numbers as orders of magnitude, not as a benchmark: one sample, one small machine.
|
|
||||||
|
|
||||||
Three things they say clearly.
|
|
||||||
|
|
||||||
**RabbitMQ ships no configuration file at all.** The boot log records `Config file(s): (none)`;
|
|
||||||
`rabbitmq.conf.example` is entirely commented out. Everything works out of the box, and the
|
|
||||||
smallest resident footprint here is the one that is not a JVM. Against that, `epmd` on 4369 and
|
|
||||||
the inter-node port on 25672 are the Erlang distribution, which is also how clustering works and
|
|
||||||
how clustering goes wrong.
|
|
||||||
|
|
||||||
**Pulsar standalone is three systems in one process.** Port 2181 is ZooKeeper and the on-disk
|
|
||||||
data is BookKeeper's; the standalone distribution hides that behind one command. A real
|
|
||||||
deployment does not: you operate ZooKeeper (or, from Pulsar 3.x, an alternative metadata store),
|
|
||||||
BookKeeper bookies and Pulsar brokers as three tiers with three scaling stories. The 357-setting
|
|
||||||
`standalone.conf` is the honest signal here — that is the configuration surface, and running it
|
|
||||||
seriously means learning most of it.
|
|
||||||
|
|
||||||
**Kafka since KRaft is genuinely simpler than it was.** One process, two ports, 24 settings, no
|
|
||||||
ZooKeeper. The old "Kafka means also running ZooKeeper" objection is a version behind; anyone
|
|
||||||
comparing on that basis is comparing to 2022.
|
|
||||||
|
|
||||||
## What the numbers do not include
|
|
||||||
|
|
||||||
The footprint above is one node at idle. The thing that actually determines ops burden is what
|
|
||||||
happens at three in the morning:
|
|
||||||
|
|
||||||
- **Kafka**: rebalances, consumer lag as the primary signal, partition-count decisions you cannot
|
|
||||||
cleanly reverse, and a broad ecosystem of tools that assume Kafka.
|
|
||||||
- **RabbitMQ**: the management plugin is genuinely good, queue depth is a direct and obvious
|
|
||||||
signal, and the hard problem is network partitions in a cluster — which the Erlang distribution
|
|
||||||
makes fast to detect and awkward to resolve.
|
|
||||||
- **Pulsar**: the fewest people on your team will have run it. That is not a technical property
|
|
||||||
and it is usually the deciding one.
|
|
||||||
|
|
||||||
next: [What Spring adds](06-what-spring-adds.md)
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
prev: [Operational footprint](05-operational-footprint.md) · [README](../README.md) · next: [The decision table](07-the-decision-table.md)
|
|
||||||
|
|
||||||
# 6. What Spring adds, and what Boot 4 no longer gives you for free
|
|
||||||
|
|
||||||
The measurements in this module drive the client libraries directly, so that what is being
|
|
||||||
compared is the brokers rather than three sets of Spring defaults. In an application you would use
|
|
||||||
the Spring integrations, and they are not equivalent to one another.
|
|
||||||
|
|
||||||
| | Kafka | RabbitMQ | Pulsar |
|
|
||||||
|---|---|---|---|
|
|
||||||
| project | Spring for Apache Kafka 4.1.1 | Spring AMQP 4.1.1 | Spring for Apache Pulsar 2.0.7 |
|
|
||||||
| starter | `spring-boot-starter-kafka` | `spring-boot-starter-amqp` | `spring-boot-starter-pulsar` |
|
|
||||||
| listener | `@KafkaListener` | `@RabbitListener` | `@PulsarListener` |
|
|
||||||
| template | `KafkaTemplate` | `RabbitTemplate` | `PulsarTemplate` |
|
|
||||||
| retry / DLQ | `DefaultErrorHandler`, `@RetryableTopic` | dead-letter exchange, container error handler | `DeadLetterPolicy` on the listener |
|
|
||||||
|
|
||||||
All three are Boot-managed at 4.1.1, so you do not pin their versions.
|
|
||||||
|
|
||||||
## The Boot 4 trap that applies to all three
|
|
||||||
|
|
||||||
**In Spring Boot 4, depending on a messaging library directly rather than through its Boot starter
|
|
||||||
means you have no auto-configuration.** The auto-configuration classes moved out of
|
|
||||||
`spring-boot-autoconfigure` into per-technology modules that only the starters bring:
|
|
||||||
|
|
||||||
| depending on | what you lose | how it presents |
|
|
||||||
|---|---|---|
|
|
||||||
| `org.springframework.kafka:spring-kafka` | `spring-boot-kafka` | `No qualifying bean of type KafkaTemplate<...>` — the context starts fine |
|
|
||||||
| `org.springframework.amqp:spring-rabbit` | `spring-boot-amqp` | no `RabbitTemplate`, no `RabbitAdmin` |
|
|
||||||
| `org.springframework.pulsar:spring-pulsar` | `spring-boot-pulsar` | no `PulsarTemplate` |
|
|
||||||
|
|
||||||
Every Boot 3 tutorial gets this wrong now, and the symptom is a missing bean rather than anything
|
|
||||||
that names the cause. Use the starters.
|
|
||||||
|
|
||||||
## The Jackson fork, in both Kafka and RabbitMQ
|
|
||||||
|
|
||||||
Boot 4 moved to Jackson 3 (`tools.jackson`), and both messaging projects ship converters for
|
|
||||||
both generations. **A `2` in the class name means the previous Jackson** — which is the opposite
|
|
||||||
of the convention you would guess:
|
|
||||||
|
|
||||||
- Spring Kafka: `JsonSerializer`/`JsonDeserializer` are Jackson 2; `JacksonJsonSerializer`,
|
|
||||||
`JacksonJsonDeserializer` and `JacksonJsonSerde` are Jackson 3.
|
|
||||||
- Spring AMQP: `Jackson2JsonMessageConverter` is Jackson 2; `JacksonJsonMessageConverter` is
|
|
||||||
Jackson 3.
|
|
||||||
|
|
||||||
Picking the wrong one gives you `SerializationException: Can't serialize data`, whose cause is
|
|
||||||
`Java 8 date/time type java.time.Instant not supported by default` — a message about date types
|
|
||||||
for what is really a wrong-library problem.
|
|
||||||
|
|
||||||
The [kafka-basics](../kafka-basics/README.md) and [rabbitmq](../rabbitmq/README.md) modules in
|
|
||||||
this repository cover both stacks in detail.
|
|
||||||
|
|
||||||
next: [The decision table](07-the-decision-table.md)
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
prev: [What Spring adds](06-what-spring-adds.md) · [README](../README.md)
|
|
||||||
|
|
||||||
# 7. The decision table
|
|
||||||
|
|
||||||
Every row below is either a measurement in `docs/output/` or a statement about operating the
|
|
||||||
thing. Nothing here is a vendor claim.
|
|
||||||
|
|
||||||
| | Kafka 4.2.1 | RabbitMQ 3.10.25 | Pulsar 4.2.4 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| ordering with one consumer | FIFO per partition | FIFO per queue | FIFO |
|
|
||||||
| ordering with many consumers | per key, always | **none** | per key with `Key_Shared`, none with `Shared` |
|
|
||||||
| who chooses that | topic design | plugin, or one consumer | each subscription, independently |
|
|
||||||
| replay after the fact | yes, within retention | **no** | yes, if retention is configured |
|
|
||||||
| second independent reader | new consumer group, any time | must be arranged in advance | new subscription, any time |
|
|
||||||
| consumer parallelism ceiling | partition count | none | none |
|
|
||||||
| changing that ceiling | repartition; breaks key→partition | nothing to change | nothing to change |
|
|
||||||
| the client-side trap | none by default | unlimited prefetch (Spring: 250) | `receiverQueueSize` 1000 |
|
|
||||||
| memory at idle | 333 MB | **115 MB** | 610 MB |
|
|
||||||
| processes to operate | 1 (KRaft) | 1 + `epmd` | broker + BookKeeper + metadata store |
|
|
||||||
| shipped default settings | 24 | **0** | 357 |
|
|
||||||
| Spring project maturity | very high | very high | good, much smaller community |
|
|
||||||
|
|
||||||
## Choosing
|
|
||||||
|
|
||||||
**Choose RabbitMQ** when the work is a queue of tasks: each message is an instruction, order
|
|
||||||
between instructions does not matter, and once it is done it is done. It is the smallest thing
|
|
||||||
that works, it has the best out-of-the-box operability of the three, and queue depth is a metric
|
|
||||||
anyone can interpret. Choose it in the knowledge that you are giving up replay permanently —
|
|
||||||
adding it later is a broker migration, not a configuration change.
|
|
||||||
|
|
||||||
**Choose Kafka** when the messages are events other people will want to read: when more than one
|
|
||||||
consumer will exist, when someone will need to reprocess history after a bug, or when ordering per
|
|
||||||
entity is part of the contract. Pay for it with a partition count decided too early, consumers
|
|
||||||
that scale only as far as that number, and an operational model where lag is the thing you watch.
|
|
||||||
KRaft has removed the old ZooKeeper objection.
|
|
||||||
|
|
||||||
**Choose Pulsar** when you genuinely need what neither of the others gives you: per-subscription
|
|
||||||
choice of ordering versus fan-out, consumer counts not bounded by a number chosen at topic
|
|
||||||
creation, or multi-tenancy with real isolation. It is the most capable design here. It is also
|
|
||||||
three systems, 357 settings, five times RabbitMQ's memory floor, and the one your team has least
|
|
||||||
experience with — and that last point decides more incidents than the first three prevent.
|
|
||||||
|
|
||||||
**If the honest answer is "we do not know yet"**, that argues for Kafka, on grounds that have
|
|
||||||
nothing to do with the technology: you can hire for it, your monitoring vendor supports it, and
|
|
||||||
the failure modes are documented by thousands of people who hit them first. That is a real
|
|
||||||
engineering argument and it is fine to make it out loud.
|
|
||||||
|
|
||||||
## The question that dissolves the choice
|
|
||||||
|
|
||||||
A surprising number of these decisions are made for a system that has one producer, one consumer
|
|
||||||
and fewer than a hundred messages a second. At that volume all three brokers work, none of the
|
|
||||||
measurements above will ever be reached, and the decision is entirely about what your team can
|
|
||||||
operate at three in the morning.
|
|
||||||
|
|
||||||
The measurements matter when you can name which row you are relying on. If you cannot, you are
|
|
||||||
picking an operational burden, not a broker — and the cheapest one to operate is the one in the
|
|
||||||
`115 MB` cell.
|
|
||||||
|
|
||||||
[README](../README.md)
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
== Operational footprint, measured on one 2-core / 3.8 GB Linux box ==
|
|
||||||
|
|
||||||
Each broker started from its shipped distribution with default configuration and a
|
|
||||||
700 MB heap cap, on Temurin JDK 21. Times are one sample on a small box: treat them as
|
|
||||||
orders of magnitude, not as a benchmark.
|
|
||||||
|
|
||||||
broker : kafka
|
|
||||||
time from launch to first
|
|
||||||
accepted connection : 10035 ms
|
|
||||||
resident memory at idle : 333 MB
|
|
||||||
server processes : 1
|
|
||||||
listening ports : 9092 (broker), 9093 (controller)
|
|
||||||
unpacked distribution size : 135 MB
|
|
||||||
settings in shipped default
|
|
||||||
configuration file : 24 (server.properties)
|
|
||||||
|
|
||||||
broker : rabbit
|
|
||||||
time from launch to first
|
|
||||||
accepted connection : 15053 ms
|
|
||||||
resident memory at idle : 115 MB
|
|
||||||
server processes : 1
|
|
||||||
listening ports : 5672 (AMQP), 4369 (epmd), 25672 (inter-node)
|
|
||||||
unpacked distribution size : 26 MB
|
|
||||||
settings in shipped default
|
|
||||||
configuration file : 0 (rabbitmq.conf.example)
|
|
||||||
epmd processes : 1
|
|
||||||
NOTE: RabbitMQ ships no configuration file at all. rabbitmq.conf.example is
|
|
||||||
entirely commented out and the boot log records "Config file(s): (none)".
|
|
||||||
Everything in that file is documentation, not a default.
|
|
||||||
|
|
||||||
broker : pulsar
|
|
||||||
time from launch to first
|
|
||||||
accepted connection : 20801 ms
|
|
||||||
resident memory at idle : 610 MB
|
|
||||||
server processes : 1
|
|
||||||
listening ports : 6650 (binary), 8080 (admin/REST), 2181 (ZooKeeper)
|
|
||||||
unpacked distribution size : 344 MB
|
|
||||||
settings in shipped default
|
|
||||||
configuration file : 357 (standalone.conf)
|
|
||||||
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
== Kafka: partitions are the ceiling on consumer parallelism ==
|
|
||||||
|
|
||||||
topic 'orders-scaling', 3 partitions, 5 consumers in one group
|
|
||||||
|
|
||||||
consumer-1 -> partitions [0]
|
|
||||||
consumer-2 -> partitions [1]
|
|
||||||
consumer-3 -> partitions [2]
|
|
||||||
consumer-4 -> no partitions (idle)
|
|
||||||
consumer-5 -> no partitions (idle)
|
|
||||||
|
|
||||||
consumers with no partitions: 2
|
|
||||||
|
|
||||||
A partition is assigned to at most one consumer in a group, so the number
|
|
||||||
of partitions is a hard ceiling on consumer parallelism. Adding consumers
|
|
||||||
beyond it adds idle processes, not throughput.
|
|
||||||
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
== Kafka: ordering is per partition ==
|
|
||||||
|
|
||||||
produced 12 records, keys D/A/F round-robin, values 1..12 in order
|
|
||||||
(keys chosen so that murmur2 spreads them: D->0, A->1, F->2. A, B and C
|
|
||||||
all hash to partition 1 with three partitions, which is worth knowing
|
|
||||||
before you decide your keys are well distributed.)
|
|
||||||
|
|
||||||
consumed in this order:
|
|
||||||
F=2@p2
|
|
||||||
F=5@p2
|
|
||||||
F=8@p2
|
|
||||||
F=11@p2
|
|
||||||
A=1@p1
|
|
||||||
A=4@p1
|
|
||||||
A=7@p1
|
|
||||||
A=10@p1
|
|
||||||
D=3@p0
|
|
||||||
D=6@p0
|
|
||||||
D=9@p0
|
|
||||||
D=12@p0
|
|
||||||
|
|
||||||
per key:
|
|
||||||
F -> partition [2] values [2, 5, 8, 11]
|
|
||||||
A -> partition [1] values [1, 4, 7, 10]
|
|
||||||
D -> partition [0] values [3, 6, 9, 12]
|
|
||||||
|
|
||||||
Order is preserved within each key because a key hashes to one partition.
|
|
||||||
Across keys it is not: the values above are not 1..12 in order, because a
|
|
||||||
consumer drains one partition's buffer before the next.
|
|
||||||
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
== Kafka: the log is the storage ==
|
|
||||||
|
|
||||||
group replay-group-1, first read : 12 records
|
|
||||||
group replay-group-2, brand new group : 12 records
|
|
||||||
group replay-group-1, after seekToBeginning: 12 records
|
|
||||||
|
|
||||||
Consuming does not remove anything. A consumer group is a cursor over a log
|
|
||||||
that the broker keeps until retention expires, so a new group, a reset
|
|
||||||
offset or a seek all read the same records again.
|
|
||||||
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
== Pulsar: no partition ceiling, but the receiver queue decides who gets the work ==
|
|
||||||
|
|
||||||
one non-partitioned topic, 40 messages, 5 consumers, Shared subscription
|
|
||||||
|
|
||||||
receiverQueueSize left at the default (1000):
|
|
||||||
consumer-1 -> 40 messages
|
|
||||||
consumer-2 -> 0 messages
|
|
||||||
consumer-3 -> 0 messages
|
|
||||||
consumer-4 -> 0 messages
|
|
||||||
consumer-5 -> 0 messages
|
|
||||||
consumers that received nothing : 4
|
|
||||||
|
|
||||||
receiverQueueSize(1):
|
|
||||||
consumer-1 -> 8 messages
|
|
||||||
consumer-2 -> 8 messages
|
|
||||||
consumer-3 -> 8 messages
|
|
||||||
consumer-4 -> 8 messages
|
|
||||||
consumer-5 -> 8 messages
|
|
||||||
consumers that received nothing : 0
|
|
||||||
|
|
||||||
The topic has no partitions and five consumers can still share the work --
|
|
||||||
in Kafka the same shape needs at least five partitions, chosen when the
|
|
||||||
topic was created. But the default receiver queue is 1000 messages, so the
|
|
||||||
first consumers to connect pull the whole 40-message backlog into their own
|
|
||||||
buffers before the rest ask for anything, and the subscription looks
|
|
||||||
broken. Which consumers win is a race and varies between runs -- one
|
|
||||||
consumer taking all forty, or two taking twenty each -- but the consumers
|
|
||||||
that lose it see nothing at all. This is the same trap as RabbitMQ's
|
|
||||||
unbounded prefetch, with a different name and a much larger default.
|
|
||||||
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
== Pulsar: the subscription type decides ==
|
|
||||||
|
|
||||||
12 messages, keys A/B/C, values 1..12 in order, two consumers per
|
|
||||||
subscription, receiverQueueSize 1 so the first consumer cannot take the
|
|
||||||
whole backlog.
|
|
||||||
|
|
||||||
Shared subscription, which consumers saw each key:
|
|
||||||
A -> [consumer-1, consumer-2]
|
|
||||||
B -> [consumer-1, consumer-2]
|
|
||||||
C -> [consumer-1, consumer-2]
|
|
||||||
|
|
||||||
Key_Shared subscription, which consumers saw each key:
|
|
||||||
A -> [consumer-1]
|
|
||||||
B -> [consumer-1]
|
|
||||||
C -> [consumer-1]
|
|
||||||
|
|
||||||
A Shared subscription round-robins individual messages, so messages with
|
|
||||||
the same key end up on different consumers and can be processed at the
|
|
||||||
same time: there is no per-key order left to speak of. Key_Shared hashes
|
|
||||||
the key to one consumer, which is Kafka's guarantee -- except that the
|
|
||||||
assignment belongs to the subscription and is recomputed as consumers come
|
|
||||||
and go, rather than being fixed by a partition count chosen when the topic
|
|
||||||
was created.
|
|
||||||
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
== Pulsar: acknowledged, but still there ==
|
|
||||||
|
|
||||||
subscription replay-sub, first read : 12 messages
|
|
||||||
subscription replay-sub, after seek(earliest): 12 messages
|
|
||||||
subscription replay-sub-2, brand new : 12 messages
|
|
||||||
|
|
||||||
Acknowledgement moves a cursor; the message itself lives in the managed
|
|
||||||
ledger. seek(MessageId) and seek(timestamp) rewind a live subscription,
|
|
||||||
which Kafka can also do by resetting offsets. What differs is the default:
|
|
||||||
Pulsar deletes a message once every subscription has acknowledged it,
|
|
||||||
unless a retention policy on the namespace says otherwise, whereas Kafka
|
|
||||||
keeps it for the retention period regardless of who read it. A Pulsar
|
|
||||||
topic with no retention policy and no subscriptions keeps nothing.
|
|
||||||
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
== RabbitMQ: consumers scale, and prefetch decides whether they actually do ==
|
|
||||||
|
|
||||||
one queue, 40 messages, 5 consumers, each taking 10 ms per message
|
|
||||||
|
|
||||||
no basicQos at all (unlimited prefetch, the AMQP default):
|
|
||||||
consumer-1 -> 40 messages
|
|
||||||
consumer-2 -> 0 messages
|
|
||||||
consumer-3 -> 0 messages
|
|
||||||
consumer-4 -> 0 messages
|
|
||||||
consumer-5 -> 0 messages
|
|
||||||
consumers that received nothing : 4
|
|
||||||
|
|
||||||
basicQos(1):
|
|
||||||
consumer-1 -> 8 messages
|
|
||||||
consumer-2 -> 9 messages
|
|
||||||
consumer-3 -> 8 messages
|
|
||||||
consumer-4 -> 8 messages
|
|
||||||
consumer-5 -> 7 messages
|
|
||||||
consumers that received nothing : 0
|
|
||||||
|
|
||||||
Every consumer on a queue competes for the same messages, so adding
|
|
||||||
consumers adds throughput and there is no structural ceiling of the kind
|
|
||||||
Kafka's partition count imposes. But with the AMQP default the broker
|
|
||||||
pushes as many messages as a consumer will take, so whichever consumer
|
|
||||||
connects first can be handed the entire backlog while the others sit idle.
|
|
||||||
basicQos is not a tuning knob you get to postpone; it is what makes the
|
|
||||||
fan-out real. Spring AMQP sets it for you --
|
|
||||||
AbstractMessageListenerContainer.DEFAULT_PREFETCH_COUNT is 250 -- which is
|
|
||||||
better than unlimited and still large enough to concentrate a small
|
|
||||||
backlog on one consumer.
|
|
||||||
|
|
||||||
The price of all this is the ordering measurement: there is no key, so
|
|
||||||
nothing constrains related messages to one consumer.
|
|
||||||
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
== RabbitMQ: FIFO per queue, per consumer ==
|
|
||||||
|
|
||||||
one queue, one consumer, 12 messages
|
|
||||||
completion order : [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
|
|
||||||
|
|
||||||
one queue, two consumers, prefetch 1, consumer-1 slower than consumer-2
|
|
||||||
completion order : [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 1, 12]
|
|
||||||
out-of-order steps: 1
|
|
||||||
|
|
||||||
A queue is FIFO and a single consumer sees it that way. The moment a second
|
|
||||||
consumer is added the broker hands the next message to whichever consumer
|
|
||||||
is free, so the order in which work finishes is no longer the order in
|
|
||||||
which it was published. There is no key: RabbitMQ has no notion of a
|
|
||||||
partition to which related messages could be pinned. Ordering across
|
|
||||||
related messages means one queue and one consumer, and therefore no
|
|
||||||
horizontal scaling for that queue -- or a consistent-hash exchange, which
|
|
||||||
is a plugin.
|
|
||||||
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
== RabbitMQ: there is nothing to replay ==
|
|
||||||
|
|
||||||
first drain of the queue : 12 messages
|
|
||||||
second drain of the queue : 0 messages
|
|
||||||
queue depth afterwards : 0
|
|
||||||
|
|
||||||
Acknowledging a message deletes it. The broker is a router with buffers,
|
|
||||||
not a log: there is no offset to rewind and no second reader that can see
|
|
||||||
what the first one consumed. Reading the same message twice means
|
|
||||||
arranging it in advance -- a second queue bound to the same exchange, or a
|
|
||||||
copy written somewhere else -- and it cannot be arranged after the fact.
|
|
||||||
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
[INFO] Running com.ankurm.brokers.KafkaComparisonTest
|
|
||||||
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 26.88 s -- in com.ankurm.brokers.KafkaComparisonTest
|
|
||||||
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
|
|
||||||
[INFO] BUILD SUCCESS
|
|
||||||
[INFO] Running com.ankurm.brokers.RabbitComparisonTest
|
|
||||||
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 4.732 s -- in com.ankurm.brokers.RabbitComparisonTest
|
|
||||||
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
|
|
||||||
[INFO] BUILD SUCCESS
|
|
||||||
[INFO] Running com.ankurm.brokers.PulsarComparisonTest
|
|
||||||
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 55.00 s -- in com.ankurm.brokers.PulsarComparisonTest
|
|
||||||
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
|
|
||||||
[INFO] BUILD SUCCESS
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
|
||||||
<modelVersion>4.0.0</modelVersion>
|
|
||||||
|
|
||||||
<!-- Every client version below is managed by spring-boot-dependencies 4.1.1, so the three
|
|
||||||
brokers are compared at the versions a Boot 4.1 application would actually get:
|
|
||||||
kafka-clients 4.2.1, amqp-client 5.30.0, pulsar-client 4.2.4. Read from
|
|
||||||
spring-boot-dependencies-4.1.1.pom, not from release announcements. -->
|
|
||||||
<parent>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-parent</artifactId>
|
|
||||||
<version>4.1.1</version>
|
|
||||||
<relativePath/>
|
|
||||||
</parent>
|
|
||||||
|
|
||||||
<groupId>com.ankurm</groupId>
|
|
||||||
<artifactId>broker-comparison</artifactId>
|
|
||||||
<version>1.0</version>
|
|
||||||
<packaging>jar</packaging>
|
|
||||||
|
|
||||||
<properties>
|
|
||||||
<java.version>25</java.version>
|
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
|
||||||
</properties>
|
|
||||||
|
|
||||||
<dependencies>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- The three client libraries, driven directly. The comparison is between the brokers'
|
|
||||||
delivery models, and going through three different Spring abstractions would put three
|
|
||||||
different sets of defaults between the measurement and the thing being measured.
|
|
||||||
What each Boot starter adds on top is covered in docs/06-what-spring-adds.md. -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.apache.kafka</groupId>
|
|
||||||
<artifactId>kafka-clients</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.rabbitmq</groupId>
|
|
||||||
<artifactId>amqp-client</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.apache.pulsar</groupId>
|
|
||||||
<artifactId>pulsar-client</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
<!-- Provides the in-process KRaft broker used by the Kafka measurements: a real Kafka
|
|
||||||
broker, no Docker. -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.kafka</groupId>
|
|
||||||
<artifactId>spring-kafka-test</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
|
|
||||||
<!-- Each broker is started by its own script and the matching tagged tests are run on
|
|
||||||
their own; see scripts/run-all.sh, which passes -Dgroups=kafka|rabbit|pulsar. Running
|
|
||||||
"mvn test" with no group runs all three and needs all three brokers up. -->
|
|
||||||
|
|
||||||
</project>
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Measures the operational footprint of one broker: how long it takes to accept a connection,
|
|
||||||
# how much memory it holds at idle, how many processes and listening ports it opens, and how
|
|
||||||
# large its shipped default configuration is.
|
|
||||||
#
|
|
||||||
# These are proxies, not a benchmark. "Ops burden" is not directly measurable, but the number of
|
|
||||||
# moving parts, the memory floor and the size of the configuration surface are, and they are what
|
|
||||||
# people are actually asking about when they ask which broker is heavier to run.
|
|
||||||
#
|
|
||||||
# scripts/footprint.sh kafka|rabbit|pulsar
|
|
||||||
set -euo pipefail
|
|
||||||
BROKER="${1:?kafka|rabbit|pulsar}"
|
|
||||||
|
|
||||||
extra=""
|
|
||||||
port_open() { (echo > "/dev/tcp/127.0.0.1/$1") 2>/dev/null; }
|
|
||||||
rss_mb() { ps -eo rss,cmd | grep -F "$1" | grep -v grep | awk '{s+=$1} END {printf "%.0f", s/1024}'; }
|
|
||||||
procs() { ps -eo cmd | grep -F "$1" | grep -cv grep; }
|
|
||||||
settings() { grep -cE '^[a-zA-Z]' "$1" 2>/dev/null || echo 0; }
|
|
||||||
|
|
||||||
start=$(date +%s%3N)
|
|
||||||
case "$BROKER" in
|
|
||||||
kafka)
|
|
||||||
: "${KAFKA_HOME:?set KAFKA_HOME}"
|
|
||||||
# Wipe whatever log.dirs points at, so the format below is not refused with
|
|
||||||
# "Invalid cluster.id ... Expected X, but read Y" from a previous run.
|
|
||||||
KAFKA_LOG_DIRS=$(grep -E '^log.dirs=' "$KAFKA_HOME/config/server.properties" | cut -d= -f2)
|
|
||||||
rm -rf ${KAFKA_LOG_DIRS//,/ }
|
|
||||||
"$KAFKA_HOME/bin/kafka-storage.sh" format --standalone -t "$("$KAFKA_HOME/bin/kafka-storage.sh" random-uuid)" \
|
|
||||||
-c "$KAFKA_HOME/config/server.properties" --ignore-formatted >/dev/null
|
|
||||||
start=$(date +%s%3N) # after formatting: kafka-storage.sh is two more JVM starts and is a
|
|
||||||
# one-off, not part of what a restart costs
|
|
||||||
setsid nohup "$KAFKA_HOME/bin/kafka-server-start.sh" "$KAFKA_HOME/config/server.properties" \
|
|
||||||
> /tmp/kafka-start.log 2>&1 < /dev/null &
|
|
||||||
for _ in $(seq 1 90); do port_open 9092 && break; sleep 1; done
|
|
||||||
ready=$(( $(date +%s%3N) - start ))
|
|
||||||
marker="kafka.Kafka"; ports="9092 (broker), 9093 (controller)"; conf="$KAFKA_HOME/config/server.properties"
|
|
||||||
dist="$KAFKA_HOME"
|
|
||||||
;;
|
|
||||||
rabbit)
|
|
||||||
: "${RABBITMQ_HOME:?set RABBITMQ_HOME}"; : "${ERL_ROOT:?set ERL_ROOT}"
|
|
||||||
scripts/rabbit-broker.sh >/dev/null
|
|
||||||
ready=$(( $(date +%s%3N) - start ))
|
|
||||||
# The broker is an Erlang VM, so the process to measure is beam.smp, not anything named
|
|
||||||
# "rabbitmq". epmd is a second, tiny process and is counted separately below.
|
|
||||||
marker="beam.smp"; ports="5672 (AMQP), 4369 (epmd), 25672 (inter-node)"
|
|
||||||
conf="$RABBITMQ_HOME/etc/rabbitmq/rabbitmq.conf.example"; dist="$RABBITMQ_HOME"
|
|
||||||
extra="epmd processes : $(procs epmd)
|
|
||||||
NOTE: RabbitMQ ships no configuration file at all. rabbitmq.conf.example is
|
|
||||||
entirely commented out and the boot log records \"Config file(s): (none)\".
|
|
||||||
Everything in that file is documentation, not a default."
|
|
||||||
;;
|
|
||||||
pulsar)
|
|
||||||
: "${PULSAR_HOME:?set PULSAR_HOME}"
|
|
||||||
scripts/pulsar-broker.sh >/dev/null
|
|
||||||
ready=$(( $(date +%s%3N) - start ))
|
|
||||||
marker="PulsarStandaloneStarter"; ports="6650 (binary), 8080 (admin/REST), 2181 (ZooKeeper)"
|
|
||||||
conf="$PULSAR_HOME/conf/standalone.conf"; dist="$PULSAR_HOME"
|
|
||||||
;;
|
|
||||||
*) echo "unknown broker $BROKER" >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
cat <<EOF
|
|
||||||
broker : $BROKER
|
|
||||||
time from launch to first
|
|
||||||
accepted connection : ${ready} ms
|
|
||||||
resident memory at idle : $(rss_mb "$marker") MB
|
|
||||||
server processes : $(procs "$marker")
|
|
||||||
listening ports : $ports
|
|
||||||
unpacked distribution size : $(du -sm "$dist" | cut -f1) MB
|
|
||||||
settings in shipped default
|
|
||||||
configuration file : $(settings "$conf") ($(basename "$conf"))
|
|
||||||
${extra:-}
|
|
||||||
EOF
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Start a real Apache Pulsar broker with no Docker and no root.
|
|
||||||
#
|
|
||||||
# The standalone distribution bundles ZooKeeper, BookKeeper and the broker in one process, which
|
|
||||||
# is why a single tarball is enough. -nss skips the stream storage (BookKeeper's table service,
|
|
||||||
# needed only by stateful Pulsar Functions) and -nfw skips the functions worker; both save
|
|
||||||
# several hundred megabytes of heap and a few seconds of startup.
|
|
||||||
#
|
|
||||||
# PULSAR_HOME must point at an unpacked apache-pulsar-<version>-bin directory. Pulsar 4.2
|
|
||||||
# supports Java 17 and 21; the transcripts here were produced on Temurin 21.
|
|
||||||
set -eu
|
|
||||||
: "${PULSAR_HOME:?set PULSAR_HOME to an unpacked apache-pulsar-*-bin directory}"
|
|
||||||
export PULSAR_MEM="${PULSAR_MEM:--Xms384m -Xmx700m -XX:MaxDirectMemorySize=384m}"
|
|
||||||
|
|
||||||
cd "$PULSAR_HOME"
|
|
||||||
setsid nohup bin/pulsar standalone -nss -nfw > /tmp/pulsar-start.log 2>&1 < /dev/null &
|
|
||||||
for _ in $(seq 1 90); do
|
|
||||||
if [ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/admin/v2/brokers/version)" = "200" ]; then
|
|
||||||
echo "broker ready on 6650 (admin 8080), version $(curl -s http://127.0.0.1:8080/admin/v2/brokers/version)"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
echo "broker did not start; see /tmp/pulsar-start.log" >&2
|
|
||||||
exit 1
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Start a real RabbitMQ broker with no Docker and no root.
|
|
||||||
#
|
|
||||||
# RabbitMQ is an Erlang application, so an Erlang runtime and epmd on the path are the whole
|
|
||||||
# dependency. Point ERL_ROOT at an Erlang installation and RABBITMQ_HOME at an unpacked
|
|
||||||
# rabbitmq-server-generic-unix tarball. Ubuntu 22.04 ships Erlang 24, whose newest compatible
|
|
||||||
# broker is 3.10.25; 3.11 and later need Erlang 25.
|
|
||||||
#
|
|
||||||
# The one non-obvious step is starting epmd yourself: rabbitmq-server's own attempt to start it
|
|
||||||
# fails in a container without a resolvable hostname, and the failure surfaces as a forty-line
|
|
||||||
# Erlang crash dump ending in {'EXIT',nodistribution}.
|
|
||||||
set -eu
|
|
||||||
: "${ERL_ROOT:?set ERL_ROOT to an Erlang installation directory}"
|
|
||||||
: "${RABBITMQ_HOME:?set RABBITMQ_HOME to an unpacked rabbitmq-server-generic-unix directory}"
|
|
||||||
|
|
||||||
export PATH="$ERL_ROOT/bin:$ERL_ROOT/erts-"*/bin":$PATH"
|
|
||||||
export RABBITMQ_MNESIA_BASE="${RABBITMQ_MNESIA_BASE:-/tmp/rmq/data}"
|
|
||||||
export RABBITMQ_LOG_BASE="${RABBITMQ_LOG_BASE:-/tmp/rmq/log}"
|
|
||||||
export RABBITMQ_NODENAME="${RABBITMQ_NODENAME:-rabbit@localhost}"
|
|
||||||
export HOME="${HOME:-/tmp/rmq}"
|
|
||||||
mkdir -p "$RABBITMQ_MNESIA_BASE" "$RABBITMQ_LOG_BASE"
|
|
||||||
|
|
||||||
epmd -daemon 2>/dev/null || true
|
|
||||||
sleep 1
|
|
||||||
setsid nohup "$RABBITMQ_HOME/sbin/rabbitmq-server" > /tmp/rmq/boot.log 2>&1 < /dev/null &
|
|
||||||
for _ in $(seq 1 60); do
|
|
||||||
if (echo > /dev/tcp/127.0.0.1/5672) 2>/dev/null; then echo "broker ready on 5672"; exit 0; fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
echo "broker did not start; see /tmp/rmq/boot.log" >&2
|
|
||||||
exit 1
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Regenerates every file under docs/output/. Each broker is started, measured and left running
|
|
||||||
# only for its own group of tests, because three brokers do not fit comfortably in memory on a
|
|
||||||
# small machine at the same time.
|
|
||||||
#
|
|
||||||
# Required environment:
|
|
||||||
# KAFKA_HOME unpacked kafka_2.13-4.2.1
|
|
||||||
# ERL_ROOT an Erlang 24 installation (RabbitMQ 3.10.x)
|
|
||||||
# RABBITMQ_HOME unpacked rabbitmq-server-generic-unix-3.10.25
|
|
||||||
# PULSAR_HOME unpacked apache-pulsar-4.2.4-bin
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$(dirname "$0")/.."
|
|
||||||
mkdir -p docs/output
|
|
||||||
|
|
||||||
# Kafka needs no external broker for the measurements: spring-kafka-test starts a real KRaft
|
|
||||||
# broker in-process. KAFKA_HOME is only used by the footprint measurement.
|
|
||||||
mvn -B -Dgroups=kafka test 2>&1 | grep -E 'Running |Tests run:|BUILD ' > docs/output/tests.txt
|
|
||||||
|
|
||||||
scripts/rabbit-broker.sh
|
|
||||||
mvn -B -Dgroups=rabbit test 2>&1 | grep -E 'Running |Tests run:|BUILD ' >> docs/output/tests.txt
|
|
||||||
|
|
||||||
scripts/pulsar-broker.sh
|
|
||||||
mvn -B -Dgroups=pulsar test 2>&1 | grep -E 'Running |Tests run:|BUILD ' >> docs/output/tests.txt
|
|
||||||
|
|
||||||
{
|
|
||||||
echo "== Operational footprint, measured on one 2-core / 3.8 GB Linux box =="
|
|
||||||
echo
|
|
||||||
echo "Each broker started from its shipped distribution with default configuration and a"
|
|
||||||
echo "700 MB heap cap, on Temurin JDK 21. Times are one sample on a small box: treat them as"
|
|
||||||
echo "orders of magnitude, not as a benchmark."
|
|
||||||
echo
|
|
||||||
scripts/footprint.sh kafka; echo
|
|
||||||
scripts/footprint.sh rabbit; echo
|
|
||||||
scripts/footprint.sh pulsar
|
|
||||||
} > docs/output/footprint.txt
|
|
||||||
|
|
||||||
echo "regenerated:"; ls -1 docs/output
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package com.ankurm.brokers;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* There is deliberately no Spring application here. The measurements in src/test drive the three
|
|
||||||
* client libraries directly so that the thing being compared is the broker's delivery model and
|
|
||||||
* not three different sets of Spring defaults.
|
|
||||||
*
|
|
||||||
* <p>What each Spring Boot starter adds on top — and what it silently does not add, which
|
|
||||||
* in Boot 4 is more than people expect — is in docs/06-what-spring-adds.md.
|
|
||||||
*/
|
|
||||||
public final class BrokerComparisonApplication {
|
|
||||||
|
|
||||||
private BrokerComparisonApplication() {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package com.ankurm.brokers;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
|
|
||||||
/** Writes a transcript under docs/output/. Nothing in the article is typed by hand. */
|
|
||||||
public final class Capture {
|
|
||||||
|
|
||||||
private Capture() {
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void write(String fileName, String heading, String body) {
|
|
||||||
Path dir = Path.of(System.getProperty("user.dir"), "docs", "output");
|
|
||||||
try {
|
|
||||||
Files.createDirectories(dir);
|
|
||||||
Files.writeString(dir.resolve(fileName), "== " + heading + " ==\n\n" + body + "\n");
|
|
||||||
}
|
|
||||||
catch (IOException ex) {
|
|
||||||
throw new IllegalStateException("could not write " + fileName, ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
package com.ankurm.brokers;
|
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Properties;
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
import org.apache.kafka.clients.consumer.ConsumerConfig;
|
|
||||||
import org.apache.kafka.clients.consumer.ConsumerRecord;
|
|
||||||
import org.apache.kafka.clients.consumer.ConsumerRecords;
|
|
||||||
import org.apache.kafka.clients.consumer.KafkaConsumer;
|
|
||||||
import org.apache.kafka.clients.producer.KafkaProducer;
|
|
||||||
import org.apache.kafka.clients.producer.ProducerConfig;
|
|
||||||
import org.apache.kafka.clients.producer.ProducerRecord;
|
|
||||||
import org.apache.kafka.common.TopicPartition;
|
|
||||||
import org.apache.kafka.common.serialization.StringDeserializer;
|
|
||||||
import org.apache.kafka.common.serialization.StringSerializer;
|
|
||||||
import org.junit.jupiter.api.AfterAll;
|
|
||||||
import org.junit.jupiter.api.BeforeAll;
|
|
||||||
import org.junit.jupiter.api.Tag;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import org.springframework.kafka.test.EmbeddedKafkaKraftBroker;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Kafka measured against a real broker: an in-process KRaft broker from spring-kafka-test, one
|
|
||||||
* node, one topic, three partitions.
|
|
||||||
*
|
|
||||||
* <p>The three questions are the same for all three brokers: what ordering survives, what can be
|
|
||||||
* replayed, and how far consumers scale.
|
|
||||||
*/
|
|
||||||
@Tag("kafka")
|
|
||||||
class KafkaComparisonTest {
|
|
||||||
|
|
||||||
private static final String ORDERING = "orders-ordering";
|
|
||||||
|
|
||||||
private static final String REPLAY = "orders-replay";
|
|
||||||
|
|
||||||
private static final String SCALING = "orders-scaling";
|
|
||||||
|
|
||||||
private static EmbeddedKafkaKraftBroker broker;
|
|
||||||
|
|
||||||
@BeforeAll
|
|
||||||
static void startBroker() {
|
|
||||||
broker = new EmbeddedKafkaKraftBroker(1, 3, ORDERING, REPLAY, SCALING);
|
|
||||||
broker.afterPropertiesSet();
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterAll
|
|
||||||
static void stopBroker() {
|
|
||||||
broker.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- ordering
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void orderingIsPerPartitionOnly() {
|
|
||||||
produce(ORDERING, 12);
|
|
||||||
|
|
||||||
List<ConsumerRecord<String, String>> received = consumeAll(ORDERING, "ordering-group", 12);
|
|
||||||
|
|
||||||
Map<String, List<String>> perKey = new LinkedHashMap<>();
|
|
||||||
Map<String, Set<Integer>> partitionsPerKey = new LinkedHashMap<>();
|
|
||||||
List<String> globalOrder = new ArrayList<>();
|
|
||||||
for (ConsumerRecord<String, String> record : received) {
|
|
||||||
perKey.computeIfAbsent(record.key(), (k) -> new ArrayList<>()).add(record.value());
|
|
||||||
partitionsPerKey.computeIfAbsent(record.key(), (k) -> new java.util.TreeSet<>())
|
|
||||||
.add(record.partition());
|
|
||||||
globalOrder.add(record.key() + "=" + record.value() + "@p" + record.partition());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per key, the sequence numbers must come back in the order they were produced. Compared
|
|
||||||
// as numbers: as strings, "10" sorts before "2" and the assertion is meaningless.
|
|
||||||
perKey.forEach((key, values) -> assertThat(values.stream().map(Integer::parseInt).toList())
|
|
||||||
.isSorted());
|
|
||||||
// Each key went to exactly one partition, which is why that holds...
|
|
||||||
partitionsPerKey.forEach((key, partitions) -> assertThat(partitions).hasSize(1));
|
|
||||||
// ...and the three keys are spread over all three partitions, so the transcript below is
|
|
||||||
// actually testing something.
|
|
||||||
assertThat(partitionsPerKey.values().stream().flatMap(Set::stream).distinct().count())
|
|
||||||
.isEqualTo(3);
|
|
||||||
List<Integer> asDelivered = globalOrder.stream()
|
|
||||||
.map((entry) -> Integer.parseInt(entry.split("=")[1].split("@")[0])).toList();
|
|
||||||
assertThat(asDelivered).isNotEqualTo(
|
|
||||||
java.util.stream.IntStream.rangeClosed(1, 12).boxed().toList());
|
|
||||||
|
|
||||||
StringBuilder body = new StringBuilder();
|
|
||||||
body.append("produced 12 records, keys D/A/F round-robin, values 1..12 in order\n")
|
|
||||||
.append("(keys chosen so that murmur2 spreads them: D->0, A->1, F->2. A, B and C\n")
|
|
||||||
.append(" all hash to partition 1 with three partitions, which is worth knowing\n")
|
|
||||||
.append(" before you decide your keys are well distributed.)\n\n");
|
|
||||||
body.append("consumed in this order:\n ").append(String.join("\n ", globalOrder))
|
|
||||||
.append("\n\nper key:\n");
|
|
||||||
perKey.forEach((key, values) -> body.append(" ").append(key).append(" -> partition ")
|
|
||||||
.append(partitionsPerKey.get(key)).append(" values ").append(values).append('\n'));
|
|
||||||
body.append("\nOrder is preserved within each key because a key hashes to one partition.\n")
|
|
||||||
.append("Across keys it is not: the values above are not 1..12 in order, because a\n")
|
|
||||||
.append("consumer drains one partition's buffer before the next.\n");
|
|
||||||
Capture.write("kafka-ordering.txt", "Kafka: ordering is per partition", body.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- replay
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void anythingStillWithinRetentionCanBeReadAgain() {
|
|
||||||
produce(REPLAY, 12);
|
|
||||||
|
|
||||||
int first = consumeAll(REPLAY, "replay-group-1", 12).size();
|
|
||||||
int again = consumeAll(REPLAY, "replay-group-2", 12).size();
|
|
||||||
|
|
||||||
List<ConsumerRecord<String, String>> third;
|
|
||||||
try (KafkaConsumer<String, String> consumer = consumer("replay-group-1")) {
|
|
||||||
consumer.subscribe(List.of(REPLAY));
|
|
||||||
consumer.poll(Duration.ofSeconds(2));
|
|
||||||
consumer.seekToBeginning(consumer.assignment());
|
|
||||||
third = drain(consumer, 12);
|
|
||||||
}
|
|
||||||
|
|
||||||
assertThat(first).isEqualTo(12);
|
|
||||||
assertThat(again).isEqualTo(12);
|
|
||||||
assertThat(third).hasSize(12);
|
|
||||||
|
|
||||||
Capture.write("kafka-replay.txt", "Kafka: the log is the storage",
|
|
||||||
"""
|
|
||||||
group replay-group-1, first read : %d records
|
|
||||||
group replay-group-2, brand new group : %d records
|
|
||||||
group replay-group-1, after seekToBeginning: %d records
|
|
||||||
|
|
||||||
Consuming does not remove anything. A consumer group is a cursor over a log
|
|
||||||
that the broker keeps until retention expires, so a new group, a reset
|
|
||||||
offset or a seek all read the same records again.
|
|
||||||
""".formatted(first, again, third.size()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- consumer scaling
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void consumerParallelismIsCappedByPartitionCount() throws Exception {
|
|
||||||
produce(SCALING, 12);
|
|
||||||
|
|
||||||
List<KafkaConsumer<String, String>> consumers = new ArrayList<>();
|
|
||||||
Map<String, Set<Integer>> assignment = new LinkedHashMap<>();
|
|
||||||
try {
|
|
||||||
for (int i = 1; i <= 5; i++) {
|
|
||||||
KafkaConsumer<String, String> consumer = consumer("scaling-group");
|
|
||||||
consumer.subscribe(List.of(SCALING));
|
|
||||||
consumers.add(consumer);
|
|
||||||
}
|
|
||||||
// Poll each consumer until the group has settled and every member knows its share.
|
|
||||||
for (int round = 0; round < 8; round++) {
|
|
||||||
for (KafkaConsumer<String, String> consumer : consumers) {
|
|
||||||
consumer.poll(Duration.ofMillis(500));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (int i = 0; i < consumers.size(); i++) {
|
|
||||||
Set<Integer> partitions = new java.util.TreeSet<>();
|
|
||||||
for (TopicPartition tp : consumers.get(i).assignment()) {
|
|
||||||
partitions.add(tp.partition());
|
|
||||||
}
|
|
||||||
assignment.put("consumer-" + (i + 1), partitions);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
consumers.forEach(KafkaConsumer::close);
|
|
||||||
}
|
|
||||||
|
|
||||||
long idle = assignment.values().stream().filter(Set::isEmpty).count();
|
|
||||||
assertThat(assignment).hasSize(5);
|
|
||||||
assertThat(idle).isEqualTo(2);
|
|
||||||
|
|
||||||
StringBuilder body = new StringBuilder("topic 'orders-scaling', 3 partitions, 5 consumers in one group\n\n");
|
|
||||||
assignment.forEach((name, partitions) -> body.append(" ").append(name).append(" -> ")
|
|
||||||
.append(partitions.isEmpty() ? "no partitions (idle)" : "partitions " + partitions)
|
|
||||||
.append('\n'));
|
|
||||||
body.append("\nconsumers with no partitions: ").append(idle).append('\n')
|
|
||||||
.append("\nA partition is assigned to at most one consumer in a group, so the number\n")
|
|
||||||
.append("of partitions is a hard ceiling on consumer parallelism. Adding consumers\n")
|
|
||||||
.append("beyond it adds idle processes, not throughput.\n");
|
|
||||||
Capture.write("kafka-consumer-scaling.txt",
|
|
||||||
"Kafka: partitions are the ceiling on consumer parallelism", body.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- helpers
|
|
||||||
|
|
||||||
private void produce(String topic, int count) {
|
|
||||||
Properties props = new Properties();
|
|
||||||
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, broker.getBrokersAsString());
|
|
||||||
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
|
|
||||||
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
|
|
||||||
// D, A and F, not A, B and C: with three partitions murmur2 maps A, B and C all to
|
|
||||||
// partition 1, so the obvious choice of keys would have produced a transcript in which
|
|
||||||
// global order happened to be preserved and proved nothing. D->0, A->1, F->2.
|
|
||||||
String[] keys = { "D", "A", "F" };
|
|
||||||
try (KafkaProducer<String, String> producer = new KafkaProducer<>(props)) {
|
|
||||||
for (int i = 1; i <= count; i++) {
|
|
||||||
producer.send(new ProducerRecord<>(topic, keys[i % keys.length], String.valueOf(i)));
|
|
||||||
}
|
|
||||||
producer.flush();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private KafkaConsumer<String, String> consumer(String group) {
|
|
||||||
Properties props = new Properties();
|
|
||||||
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, broker.getBrokersAsString());
|
|
||||||
props.put(ConsumerConfig.GROUP_ID_CONFIG, group);
|
|
||||||
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
|
|
||||||
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
|
|
||||||
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
|
|
||||||
return new KafkaConsumer<>(props);
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<ConsumerRecord<String, String>> consumeAll(String topic, String group, int expected) {
|
|
||||||
try (KafkaConsumer<String, String> consumer = consumer(group)) {
|
|
||||||
consumer.subscribe(List.of(topic));
|
|
||||||
return drain(consumer, expected);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<ConsumerRecord<String, String>> drain(KafkaConsumer<String, String> consumer, int expected) {
|
|
||||||
List<ConsumerRecord<String, String>> received = new ArrayList<>();
|
|
||||||
for (int i = 0; i < 20 && received.size() < expected; i++) {
|
|
||||||
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
|
|
||||||
records.forEach(received::add);
|
|
||||||
}
|
|
||||||
return received;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
package com.ankurm.brokers;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
import org.apache.pulsar.client.api.Consumer;
|
|
||||||
import org.apache.pulsar.client.api.Message;
|
|
||||||
import org.apache.pulsar.client.api.MessageId;
|
|
||||||
import org.apache.pulsar.client.api.Producer;
|
|
||||||
import org.apache.pulsar.client.api.PulsarClient;
|
|
||||||
import org.apache.pulsar.client.api.Schema;
|
|
||||||
import org.apache.pulsar.client.api.SubscriptionInitialPosition;
|
|
||||||
import org.apache.pulsar.client.api.SubscriptionType;
|
|
||||||
import org.junit.jupiter.api.AfterAll;
|
|
||||||
import org.junit.jupiter.api.BeforeAll;
|
|
||||||
import org.junit.jupiter.api.Tag;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pulsar measured against a real standalone broker on localhost:6650, started by
|
|
||||||
* scripts/pulsar-broker.sh.
|
|
||||||
*
|
|
||||||
* <p>Pulsar is the interesting third option because it does not force the trade the other two
|
|
||||||
* force. The subscription type is chosen per subscriber rather than being a property of the
|
|
||||||
* topic, so ordering and consumer fan-out are separate decisions.
|
|
||||||
*/
|
|
||||||
@Tag("pulsar")
|
|
||||||
class PulsarComparisonTest {
|
|
||||||
|
|
||||||
private static PulsarClient client;
|
|
||||||
|
|
||||||
@BeforeAll
|
|
||||||
static void connect() throws Exception {
|
|
||||||
client = PulsarClient.builder().serviceUrl("pulsar://127.0.0.1:6650").build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterAll
|
|
||||||
static void close() throws Exception {
|
|
||||||
client.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- ordering
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void sharedSpreadsAKeyAndKeySharedPinsIt() throws Exception {
|
|
||||||
String topic = "persistent://public/default/ordering-" + System.nanoTime();
|
|
||||||
produce(topic, 12);
|
|
||||||
|
|
||||||
Map<String, Set<String>> shared = consumersPerKey(topic, "sub-shared", SubscriptionType.Shared, 12);
|
|
||||||
Map<String, Set<String>> keyShared = consumersPerKey(topic, "sub-keyshared",
|
|
||||||
SubscriptionType.Key_Shared, 12);
|
|
||||||
|
|
||||||
// Key_Shared: every key was handled by exactly one consumer, so per-key order is safe.
|
|
||||||
keyShared.forEach((key, handlers) -> assertThat(handlers).hasSize(1));
|
|
||||||
// Shared: at least one key was handled by both, so two messages with that key were in
|
|
||||||
// flight on two consumers and nothing orders them.
|
|
||||||
assertThat(shared.values().stream().anyMatch((handlers) -> handlers.size() > 1)).isTrue();
|
|
||||||
|
|
||||||
Capture.write("pulsar-ordering.txt", "Pulsar: the subscription type decides",
|
|
||||||
"""
|
|
||||||
12 messages, keys A/B/C, values 1..12 in order, two consumers per
|
|
||||||
subscription, receiverQueueSize 1 so the first consumer cannot take the
|
|
||||||
whole backlog.
|
|
||||||
|
|
||||||
Shared subscription, which consumers saw each key:
|
|
||||||
%s
|
|
||||||
Key_Shared subscription, which consumers saw each key:
|
|
||||||
%s
|
|
||||||
A Shared subscription round-robins individual messages, so messages with
|
|
||||||
the same key end up on different consumers and can be processed at the
|
|
||||||
same time: there is no per-key order left to speak of. Key_Shared hashes
|
|
||||||
the key to one consumer, which is Kafka's guarantee -- except that the
|
|
||||||
assignment belongs to the subscription and is recomputed as consumers come
|
|
||||||
and go, rather than being fixed by a partition count chosen when the topic
|
|
||||||
was created.
|
|
||||||
""".formatted(render3(shared), render3(keyShared)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- replay
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void aSubscriptionCanBeRewound() throws Exception {
|
|
||||||
String topic = "persistent://public/default/replay-" + System.nanoTime();
|
|
||||||
produce(topic, 12);
|
|
||||||
|
|
||||||
int first;
|
|
||||||
int afterSeek;
|
|
||||||
try (Consumer<String> consumer = consumer(topic, "replay-sub", SubscriptionType.Exclusive)) {
|
|
||||||
first = drain(consumer, 12).size();
|
|
||||||
consumer.seek(MessageId.earliest);
|
|
||||||
afterSeek = drain(consumer, 12).size();
|
|
||||||
}
|
|
||||||
|
|
||||||
int newSubscription;
|
|
||||||
try (Consumer<String> consumer = consumer(topic, "replay-sub-2", SubscriptionType.Exclusive)) {
|
|
||||||
newSubscription = drain(consumer, 12).size();
|
|
||||||
}
|
|
||||||
|
|
||||||
assertThat(first).isEqualTo(12);
|
|
||||||
assertThat(afterSeek).isEqualTo(12);
|
|
||||||
assertThat(newSubscription).isEqualTo(12);
|
|
||||||
|
|
||||||
Capture.write("pulsar-replay.txt", "Pulsar: acknowledged, but still there",
|
|
||||||
"""
|
|
||||||
subscription replay-sub, first read : %d messages
|
|
||||||
subscription replay-sub, after seek(earliest): %d messages
|
|
||||||
subscription replay-sub-2, brand new : %d messages
|
|
||||||
|
|
||||||
Acknowledgement moves a cursor; the message itself lives in the managed
|
|
||||||
ledger. seek(MessageId) and seek(timestamp) rewind a live subscription,
|
|
||||||
which Kafka can also do by resetting offsets. What differs is the default:
|
|
||||||
Pulsar deletes a message once every subscription has acknowledged it,
|
|
||||||
unless a retention policy on the namespace says otherwise, whereas Kafka
|
|
||||||
keeps it for the retention period regardless of who read it. A Pulsar
|
|
||||||
topic with no retention policy and no subscriptions keeps nothing.
|
|
||||||
""".formatted(first, afterSeek, newSubscription));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- consumer scaling
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void sharedSubscriptionsHaveNoPartitionCeilingButHaveAReceiverQueue() throws Exception {
|
|
||||||
String topicA = "persistent://public/default/scaling-default-" + System.nanoTime();
|
|
||||||
produce(topicA, 40);
|
|
||||||
Map<String, Integer> withDefaultQueue = drainAcross(topicA, "scaling-sub", 5, 40, 0);
|
|
||||||
|
|
||||||
String topicB = "persistent://public/default/scaling-q1-" + System.nanoTime();
|
|
||||||
produce(topicB, 40);
|
|
||||||
Map<String, Integer> withQueueOfOne = drainAcross(topicB, "scaling-sub", 5, 40, 1);
|
|
||||||
|
|
||||||
long idleDefault = withDefaultQueue.values().stream().filter((n) -> n == 0).count();
|
|
||||||
long idleTuned = withQueueOfOne.values().stream().filter((n) -> n == 0).count();
|
|
||||||
|
|
||||||
assertThat(idleTuned).isZero();
|
|
||||||
assertThat(idleDefault).isGreaterThan(0);
|
|
||||||
|
|
||||||
Capture.write("pulsar-consumer-scaling.txt",
|
|
||||||
"Pulsar: no partition ceiling, but the receiver queue decides who gets the work",
|
|
||||||
"""
|
|
||||||
one non-partitioned topic, 40 messages, 5 consumers, Shared subscription
|
|
||||||
|
|
||||||
receiverQueueSize left at the default (1000):
|
|
||||||
%s consumers that received nothing : %d
|
|
||||||
|
|
||||||
receiverQueueSize(1):
|
|
||||||
%s consumers that received nothing : %d
|
|
||||||
|
|
||||||
The topic has no partitions and five consumers can still share the work --
|
|
||||||
in Kafka the same shape needs at least five partitions, chosen when the
|
|
||||||
topic was created. But the default receiver queue is 1000 messages, so the
|
|
||||||
first consumers to connect pull the whole 40-message backlog into their own
|
|
||||||
buffers before the rest ask for anything, and the subscription looks
|
|
||||||
broken. Which consumers win is a race and varies between runs -- one
|
|
||||||
consumer taking all forty, or two taking twenty each -- but the consumers
|
|
||||||
that lose it see nothing at all. This is the same trap as RabbitMQ's
|
|
||||||
unbounded prefetch, with a different name and a much larger default.
|
|
||||||
""".formatted(render2(withDefaultQueue), idleDefault,
|
|
||||||
render2(withQueueOfOne), idleTuned));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- helpers
|
|
||||||
|
|
||||||
private void produce(String topic, int count) throws Exception {
|
|
||||||
String[] keys = { "A", "B", "C" };
|
|
||||||
try (Producer<String> producer = client.newProducer(Schema.STRING).topic(topic).create()) {
|
|
||||||
for (int i = 1; i <= count; i++) {
|
|
||||||
producer.newMessage().key(keys[i % keys.length]).value(String.valueOf(i)).send();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Consumer<String> consumer(String topic, String subscription, SubscriptionType type)
|
|
||||||
throws Exception {
|
|
||||||
return consumer(topic, subscription, type, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param receiverQueueSize 0 leaves Pulsar's default of 1000 in place; anything else sets it.
|
|
||||||
* The default matters more than it looks: it is how many messages a single consumer will pull
|
|
||||||
* into its own buffer before another consumer on the same subscription gets a chance.
|
|
||||||
*/
|
|
||||||
private Consumer<String> consumer(String topic, String subscription, SubscriptionType type,
|
|
||||||
int receiverQueueSize) throws Exception {
|
|
||||||
var builder = client.newConsumer(Schema.STRING)
|
|
||||||
.topic(topic)
|
|
||||||
.subscriptionName(subscription)
|
|
||||||
.subscriptionType(type)
|
|
||||||
.subscriptionInitialPosition(SubscriptionInitialPosition.Earliest);
|
|
||||||
if (receiverQueueSize > 0) {
|
|
||||||
builder = builder.receiverQueueSize(receiverQueueSize);
|
|
||||||
}
|
|
||||||
return builder.subscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<String> drain(Consumer<String> consumer, int expected) throws Exception {
|
|
||||||
List<String> received = new ArrayList<>();
|
|
||||||
while (received.size() < expected) {
|
|
||||||
Message<String> message = consumer.receive(2, TimeUnit.SECONDS);
|
|
||||||
if (message == null) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
received.add(message.getValue());
|
|
||||||
consumer.acknowledge(message);
|
|
||||||
}
|
|
||||||
return received;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Which consumers saw each key, with a receiver queue of one so nothing hoards. */
|
|
||||||
private Map<String, Set<String>> consumersPerKey(String topic, String subscription,
|
|
||||||
SubscriptionType type, int expected) throws Exception {
|
|
||||||
Map<String, Set<String>> perKey = new LinkedHashMap<>();
|
|
||||||
List<Consumer<String>> consumers = new ArrayList<>();
|
|
||||||
try {
|
|
||||||
consumers.add(consumer(topic, subscription, type, 1));
|
|
||||||
consumers.add(consumer(topic, subscription, type, 1));
|
|
||||||
int received = 0;
|
|
||||||
while (received < expected) {
|
|
||||||
boolean any = false;
|
|
||||||
for (int i = 0; i < consumers.size(); i++) {
|
|
||||||
Message<String> message = consumers.get(i).receive(500, TimeUnit.MILLISECONDS);
|
|
||||||
if (message != null) {
|
|
||||||
perKey.computeIfAbsent(message.getKey(), (k) -> new java.util.TreeSet<>())
|
|
||||||
.add("consumer-" + (i + 1));
|
|
||||||
consumers.get(i).acknowledge(message);
|
|
||||||
received++;
|
|
||||||
any = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!any) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
for (Consumer<String> consumer : consumers) {
|
|
||||||
consumer.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return perKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drains a Shared subscription across {@code n} consumers and reports the distribution. */
|
|
||||||
private Map<String, Integer> drainAcross(String topic, String subscription, int n, int expected,
|
|
||||||
int receiverQueueSize) throws Exception {
|
|
||||||
Map<String, Integer> counts = new LinkedHashMap<>();
|
|
||||||
List<Consumer<String>> consumers = new ArrayList<>();
|
|
||||||
try {
|
|
||||||
for (int i = 1; i <= n; i++) {
|
|
||||||
consumers.add(consumer(topic, subscription, SubscriptionType.Shared, receiverQueueSize));
|
|
||||||
counts.put("consumer-" + i, 0);
|
|
||||||
}
|
|
||||||
int received = 0;
|
|
||||||
while (received < expected) {
|
|
||||||
boolean any = false;
|
|
||||||
for (int i = 0; i < consumers.size(); i++) {
|
|
||||||
Message<String> message = consumers.get(i).receive(300, TimeUnit.MILLISECONDS);
|
|
||||||
if (message != null) {
|
|
||||||
consumers.get(i).acknowledge(message);
|
|
||||||
counts.merge("consumer-" + (i + 1), 1, Integer::sum);
|
|
||||||
received++;
|
|
||||||
any = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!any) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
for (Consumer<String> consumer : consumers) {
|
|
||||||
consumer.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return counts;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String render3(Map<String, Set<String>> perKey) {
|
|
||||||
StringBuilder out = new StringBuilder();
|
|
||||||
perKey.entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach((entry) ->
|
|
||||||
out.append(" ").append(entry.getKey()).append(" -> ").append(entry.getValue())
|
|
||||||
.append('\n'));
|
|
||||||
return out.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String render2(Map<String, Integer> counts) {
|
|
||||||
StringBuilder out = new StringBuilder();
|
|
||||||
counts.entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach((entry) ->
|
|
||||||
out.append(" ").append(entry.getKey()).append(" -> ").append(entry.getValue())
|
|
||||||
.append(" messages\n"));
|
|
||||||
return out.toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
package com.ankurm.brokers;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
import java.util.concurrent.CopyOnWriteArrayList;
|
|
||||||
import java.util.concurrent.CountDownLatch;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
|
||||||
|
|
||||||
import com.rabbitmq.client.AMQP;
|
|
||||||
import com.rabbitmq.client.Channel;
|
|
||||||
import com.rabbitmq.client.Connection;
|
|
||||||
import com.rabbitmq.client.ConnectionFactory;
|
|
||||||
import com.rabbitmq.client.DefaultConsumer;
|
|
||||||
import com.rabbitmq.client.Envelope;
|
|
||||||
import com.rabbitmq.client.GetResponse;
|
|
||||||
import org.junit.jupiter.api.AfterAll;
|
|
||||||
import org.junit.jupiter.api.BeforeAll;
|
|
||||||
import org.junit.jupiter.api.Tag;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RabbitMQ measured against a real broker on localhost:5672, started by
|
|
||||||
* scripts/rabbit-broker.sh.
|
|
||||||
*
|
|
||||||
* <p>The same three questions as the Kafka measurements, against a broker that owns the message
|
|
||||||
* until it is acknowledged rather than a log that keeps it.
|
|
||||||
*/
|
|
||||||
@Tag("rabbit")
|
|
||||||
class RabbitComparisonTest {
|
|
||||||
|
|
||||||
private static Connection connection;
|
|
||||||
|
|
||||||
@BeforeAll
|
|
||||||
static void connect() throws Exception {
|
|
||||||
ConnectionFactory factory = new ConnectionFactory();
|
|
||||||
factory.setHost("127.0.0.1");
|
|
||||||
factory.setPort(5672);
|
|
||||||
connection = factory.newConnection();
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterAll
|
|
||||||
static void close() throws Exception {
|
|
||||||
connection.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- ordering
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void oneConsumerIsFifoAndTwoConsumersAreNot() throws Exception {
|
|
||||||
String single = declare("order-single");
|
|
||||||
publish(single, 12);
|
|
||||||
List<String> fifo = getAll(single, 12);
|
|
||||||
|
|
||||||
String shared = declare("order-shared");
|
|
||||||
publish(shared, 12);
|
|
||||||
List<String> completionOrder = consumeWithTwoConsumers(shared, 12);
|
|
||||||
|
|
||||||
assertThat(fifo).containsExactly("1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12");
|
|
||||||
|
|
||||||
int inversions = 0;
|
|
||||||
for (int i = 1; i < completionOrder.size(); i++) {
|
|
||||||
if (Integer.parseInt(completionOrder.get(i)) < Integer.parseInt(completionOrder.get(i - 1))) {
|
|
||||||
inversions++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Capture.write("rabbit-ordering.txt", "RabbitMQ: FIFO per queue, per consumer",
|
|
||||||
"""
|
|
||||||
one queue, one consumer, 12 messages
|
|
||||||
completion order : %s
|
|
||||||
|
|
||||||
one queue, two consumers, prefetch 1, consumer-1 slower than consumer-2
|
|
||||||
completion order : %s
|
|
||||||
out-of-order steps: %d
|
|
||||||
|
|
||||||
A queue is FIFO and a single consumer sees it that way. The moment a second
|
|
||||||
consumer is added the broker hands the next message to whichever consumer
|
|
||||||
is free, so the order in which work finishes is no longer the order in
|
|
||||||
which it was published. There is no key: RabbitMQ has no notion of a
|
|
||||||
partition to which related messages could be pinned. Ordering across
|
|
||||||
related messages means one queue and one consumer, and therefore no
|
|
||||||
horizontal scaling for that queue -- or a consistent-hash exchange, which
|
|
||||||
is a plugin.
|
|
||||||
""".formatted(fifo, completionOrder, inversions));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- replay
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void anAcknowledgedMessageIsGone() throws Exception {
|
|
||||||
String queue = declare("replay-probe");
|
|
||||||
publish(queue, 12);
|
|
||||||
|
|
||||||
List<String> first = getAll(queue, 12);
|
|
||||||
List<String> second = getAll(queue, 12);
|
|
||||||
|
|
||||||
int depth = depth(queue);
|
|
||||||
|
|
||||||
assertThat(first).hasSize(12);
|
|
||||||
assertThat(second).isEmpty();
|
|
||||||
assertThat(depth).isZero();
|
|
||||||
|
|
||||||
Capture.write("rabbit-replay.txt", "RabbitMQ: there is nothing to replay",
|
|
||||||
"""
|
|
||||||
first drain of the queue : %d messages
|
|
||||||
second drain of the queue : %d messages
|
|
||||||
queue depth afterwards : %d
|
|
||||||
|
|
||||||
Acknowledging a message deletes it. The broker is a router with buffers,
|
|
||||||
not a log: there is no offset to rewind and no second reader that can see
|
|
||||||
what the first one consumed. Reading the same message twice means
|
|
||||||
arranging it in advance -- a second queue bound to the same exchange, or a
|
|
||||||
copy written somewhere else -- and it cannot be arranged after the fact.
|
|
||||||
""".formatted(first.size(), second.size(), depth));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- consumer scaling
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void consumersScaleWithoutAPartitionCeilingButPrefetchDecidesWho() throws Exception {
|
|
||||||
String unlimited = declare("scaling-unlimited");
|
|
||||||
publish(unlimited, 40);
|
|
||||||
Map<String, Integer> withDefaultPrefetch = drainAcross(unlimited, 5, 40, 0);
|
|
||||||
|
|
||||||
String throttled = declare("scaling-prefetch-1");
|
|
||||||
publish(throttled, 40);
|
|
||||||
Map<String, Integer> withPrefetchOne = drainAcross(throttled, 5, 40, 1);
|
|
||||||
|
|
||||||
long idleDefault = withDefaultPrefetch.values().stream().filter((n) -> n == 0).count();
|
|
||||||
long idleTuned = withPrefetchOne.values().stream().filter((n) -> n == 0).count();
|
|
||||||
|
|
||||||
assertThat(idleTuned).isZero();
|
|
||||||
|
|
||||||
Capture.write("rabbit-consumer-scaling.txt",
|
|
||||||
"RabbitMQ: consumers scale, and prefetch decides whether they actually do",
|
|
||||||
"""
|
|
||||||
one queue, 40 messages, 5 consumers, each taking 10 ms per message
|
|
||||||
|
|
||||||
no basicQos at all (unlimited prefetch, the AMQP default):
|
|
||||||
%s consumers that received nothing : %d
|
|
||||||
|
|
||||||
basicQos(1):
|
|
||||||
%s consumers that received nothing : %d
|
|
||||||
|
|
||||||
Every consumer on a queue competes for the same messages, so adding
|
|
||||||
consumers adds throughput and there is no structural ceiling of the kind
|
|
||||||
Kafka's partition count imposes. But with the AMQP default the broker
|
|
||||||
pushes as many messages as a consumer will take, so whichever consumer
|
|
||||||
connects first can be handed the entire backlog while the others sit idle.
|
|
||||||
basicQos is not a tuning knob you get to postpone; it is what makes the
|
|
||||||
fan-out real. Spring AMQP sets it for you --
|
|
||||||
AbstractMessageListenerContainer.DEFAULT_PREFETCH_COUNT is 250 -- which is
|
|
||||||
better than unlimited and still large enough to concentrate a small
|
|
||||||
backlog on one consumer.
|
|
||||||
|
|
||||||
The price of all this is the ordering measurement: there is no key, so
|
|
||||||
nothing constrains related messages to one consumer.
|
|
||||||
""".formatted(render(withDefaultPrefetch), idleDefault,
|
|
||||||
render(withPrefetchOne), idleTuned));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- helpers
|
|
||||||
|
|
||||||
private String declare(String name) throws Exception {
|
|
||||||
try (Channel channel = connection.createChannel()) {
|
|
||||||
channel.queueDelete(name);
|
|
||||||
channel.queueDeclare(name, false, false, true, null);
|
|
||||||
}
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void publish(String queue, int count) throws Exception {
|
|
||||||
try (Channel channel = connection.createChannel()) {
|
|
||||||
for (int i = 1; i <= count; i++) {
|
|
||||||
channel.basicPublish("", queue, null, String.valueOf(i).getBytes(StandardCharsets.UTF_8));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<String> getAll(String queue, int max) throws Exception {
|
|
||||||
List<String> received = new ArrayList<>();
|
|
||||||
try (Channel channel = connection.createChannel()) {
|
|
||||||
for (int i = 0; i < max; i++) {
|
|
||||||
GetResponse response = channel.basicGet(queue, true);
|
|
||||||
if (response == null) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
received.add(new String(response.getBody(), StandardCharsets.UTF_8));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return received;
|
|
||||||
}
|
|
||||||
|
|
||||||
private int depth(String queue) throws Exception {
|
|
||||||
try (Channel channel = connection.createChannel()) {
|
|
||||||
return channel.queueDeclarePassive(queue).getMessageCount();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<String> consumeWithTwoConsumers(String queue, int count) throws Exception {
|
|
||||||
List<String> completion = new CopyOnWriteArrayList<>();
|
|
||||||
CountDownLatch done = new CountDownLatch(count);
|
|
||||||
List<Channel> channels = new ArrayList<>();
|
|
||||||
for (int i = 1; i <= 2; i++) {
|
|
||||||
long delay = (i == 1) ? 60 : 5;
|
|
||||||
Channel channel = connection.createChannel();
|
|
||||||
channel.basicQos(1);
|
|
||||||
channels.add(channel);
|
|
||||||
channel.basicConsume(queue, false, new DefaultConsumer(channel) {
|
|
||||||
@Override
|
|
||||||
public void handleDelivery(String tag, Envelope envelope, AMQP.BasicProperties props,
|
|
||||||
byte[] body) throws IOException {
|
|
||||||
try {
|
|
||||||
Thread.sleep(delay);
|
|
||||||
}
|
|
||||||
catch (InterruptedException ex) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
}
|
|
||||||
completion.add(new String(body, StandardCharsets.UTF_8));
|
|
||||||
getChannel().basicAck(envelope.getDeliveryTag(), false);
|
|
||||||
done.countDown();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
done.await(30, TimeUnit.SECONDS);
|
|
||||||
for (Channel channel : channels) {
|
|
||||||
channel.close();
|
|
||||||
}
|
|
||||||
return List.copyOf(completion);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drains a queue across {@code n} competing consumers. prefetch 0 means "do not set it". */
|
|
||||||
private Map<String, Integer> drainAcross(String queue, int n, int expected, int prefetch)
|
|
||||||
throws Exception {
|
|
||||||
Map<String, AtomicInteger> perConsumer = new ConcurrentHashMap<>();
|
|
||||||
CountDownLatch done = new CountDownLatch(expected);
|
|
||||||
List<Channel> channels = new ArrayList<>();
|
|
||||||
for (int i = 1; i <= n; i++) {
|
|
||||||
String name = "consumer-" + i;
|
|
||||||
Channel channel = connection.createChannel();
|
|
||||||
if (prefetch > 0) {
|
|
||||||
channel.basicQos(prefetch);
|
|
||||||
}
|
|
||||||
channels.add(channel);
|
|
||||||
perConsumer.put(name, new AtomicInteger());
|
|
||||||
channel.basicConsume(queue, false, new DefaultConsumer(channel) {
|
|
||||||
@Override
|
|
||||||
public void handleDelivery(String tag, Envelope envelope, AMQP.BasicProperties props,
|
|
||||||
byte[] body) throws IOException {
|
|
||||||
perConsumer.get(name).incrementAndGet();
|
|
||||||
try {
|
|
||||||
Thread.sleep(10);
|
|
||||||
}
|
|
||||||
catch (InterruptedException ex) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
}
|
|
||||||
getChannel().basicAck(envelope.getDeliveryTag(), false);
|
|
||||||
done.countDown();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
done.await(30, TimeUnit.SECONDS);
|
|
||||||
for (Channel channel : channels) {
|
|
||||||
channel.close();
|
|
||||||
}
|
|
||||||
Map<String, Integer> counts = new LinkedHashMap<>();
|
|
||||||
perConsumer.forEach((name, count) -> counts.put(name, count.get()));
|
|
||||||
return counts;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String render(Map<String, Integer> counts) {
|
|
||||||
StringBuilder out = new StringBuilder();
|
|
||||||
counts.entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach((entry) ->
|
|
||||||
out.append(" ").append(entry.getKey()).append(" -> ").append(entry.getValue())
|
|
||||||
.append(" messages\n"));
|
|
||||||
return out.toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# kafka-streams
|
|
||||||
|
|
||||||
Companion code for *Kafka Streams with Spring Boot 4: Real-Time Fraud Detection with Vector
|
|
||||||
Similarity* on [ankurm.com](https://ankurm.com). The whole topology runs under
|
|
||||||
`TopologyTestDriver`, so **no Kafka broker is needed** to run anything here.
|
|
||||||
|
|
||||||
| Stack | Version |
|
|
||||||
|---|---|
|
|
||||||
| JDK | 25 (Temurin 25.0.4.1+1) |
|
|
||||||
| Spring Boot | 4.1.1 |
|
|
||||||
| Spring for Apache Kafka | 4.1.1 |
|
|
||||||
| kafka-streams / kafka-streams-test-utils | 4.2.1 (what Boot 4.1.1 manages) |
|
|
||||||
|
|
||||||
## Run it
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/run-all.sh # runs the tests, rewrites output/*.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
## What is where
|
|
||||||
|
|
||||||
| File | Role |
|
|
||||||
|---|---|
|
|
||||||
| `FraudTopology` | The topology as plain Kafka Streams code: similarity scoring, 5-minute velocity count, blocklist KTable join |
|
|
||||||
| `Embedder` | A hand-built 6-number feature vector and cosine similarity (not a learned model) |
|
|
||||||
| `FraudStreamsConfig` | The only Spring wiring: `@EnableKafkaStreams` plus handing the `StreamsBuilder` to the topology |
|
|
||||||
| `FraudTopologyTest` | Five tests; each one writes the matching `output/NN-*.txt` |
|
|
||||||
| `ContextLoadsTest` | Loads the Spring context with `auto-startup=false` to show the wiring without a broker |
|
|
||||||
|
|
||||||
## Captured output
|
|
||||||
|
|
||||||
Every file in `output/` is written by a test that also asserts it.
|
|
||||||
|
|
||||||
| File | Shows |
|
|
||||||
|---|---|
|
|
||||||
| `01-similarity-scores.txt` | Scores for five sample transactions against the 0.98 threshold |
|
|
||||||
| `02-similarity-alerts.txt` | Only the fraud-shaped transaction produces an alert |
|
|
||||||
| `03-velocity-window.txt` | Six transactions in two minutes give one alert; the next window starts from one |
|
|
||||||
| `04-blocklist-table-join.txt` | Join before blocking, after blocking, and after a tombstone |
|
|
||||||
| `05-topology-description.txt` | `Topology.describe()` of the whole graph |
|
|
||||||
| `06-spring-context.txt` | The Spring factory bean is wired and not running |
|
|
||||||
| `07-ambiguous-null-tombstone.txt` | The real `javac` error for `pipeInput("c5", null)`; source in `src/broken/`, regenerated by `scripts/compile-broken.sh` |
|
|
||||||
|
|
||||||
The article carries the explanations; there is deliberately no `docs/` folder.
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
c1,4700.0,3,crypto score=1.0000
|
|
||||||
c1,3000.0,2,electronics score=1.0000
|
|
||||||
c1,45.0,18,grocery score=-0.1185
|
|
||||||
c1,120.0,13,electronics score=0.1387
|
|
||||||
c1,2500.0,14,crypto score=0.2136
|
|
||||||
threshold: 0.98
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
alerts <- c1 SIMILAR:1.0000:c1,4700.0,3,crypto
|
|
||||||
alerts emitted: 1
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
alerts <- c9 VELOCITY:4 in 5m
|
|
||||||
alerts emitted for 6 transactions in 2 minutes: 1
|
|
||||||
alerts after one more transaction 10 minutes later: 0
|
|
||||||
state store window 2026-10-14T10:00:00Z count=6
|
|
||||||
state store window 2026-10-14T10:10:00Z count=1
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
before blocking, alerts: 0
|
|
||||||
alerts <- c5 BLOCKED:reported stolen
|
|
||||||
after the tombstone, alerts: 0
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
Topologies:
|
|
||||||
Sub-topology: 0
|
|
||||||
Source: KSTREAM-SOURCE-0000000000 (topics: [transactions])
|
|
||||||
--> KSTREAM-AGGREGATE-0000000005, KSTREAM-JOIN-0000000013, KSTREAM-MAPVALUES-0000000001
|
|
||||||
Processor: KSTREAM-AGGREGATE-0000000005 (stores: [txn-count-5m])
|
|
||||||
--> KTABLE-TOSTREAM-0000000006
|
|
||||||
<-- KSTREAM-SOURCE-0000000000
|
|
||||||
Processor: KSTREAM-MAPVALUES-0000000001 (stores: [])
|
|
||||||
--> score
|
|
||||||
<-- KSTREAM-SOURCE-0000000000
|
|
||||||
Processor: KTABLE-TOSTREAM-0000000006 (stores: [])
|
|
||||||
--> KSTREAM-FILTER-0000000007
|
|
||||||
<-- KSTREAM-AGGREGATE-0000000005
|
|
||||||
Processor: KSTREAM-FILTER-0000000007 (stores: [])
|
|
||||||
--> KSTREAM-MAP-0000000008
|
|
||||||
<-- KTABLE-TOSTREAM-0000000006
|
|
||||||
Processor: score (stores: [])
|
|
||||||
--> KSTREAM-FILTER-0000000003
|
|
||||||
<-- KSTREAM-MAPVALUES-0000000001
|
|
||||||
Processor: KSTREAM-FILTER-0000000003 (stores: [])
|
|
||||||
--> KSTREAM-SINK-0000000004
|
|
||||||
<-- score
|
|
||||||
Processor: KSTREAM-JOIN-0000000013 (stores: [blocked-cards-STATE-STORE-0000000010])
|
|
||||||
--> KSTREAM-SINK-0000000014
|
|
||||||
<-- KSTREAM-SOURCE-0000000000
|
|
||||||
Processor: KSTREAM-MAP-0000000008 (stores: [])
|
|
||||||
--> KSTREAM-SINK-0000000009
|
|
||||||
<-- KSTREAM-FILTER-0000000007
|
|
||||||
Source: KSTREAM-SOURCE-0000000011 (topics: [blocked-cards])
|
|
||||||
--> KTABLE-SOURCE-0000000012
|
|
||||||
Sink: KSTREAM-SINK-0000000004 (topic: alerts)
|
|
||||||
<-- KSTREAM-FILTER-0000000003
|
|
||||||
Sink: KSTREAM-SINK-0000000009 (topic: alerts)
|
|
||||||
<-- KSTREAM-MAP-0000000008
|
|
||||||
Sink: KSTREAM-SINK-0000000014 (topic: alerts)
|
|
||||||
<-- KSTREAM-JOIN-0000000013
|
|
||||||
Processor: KTABLE-SOURCE-0000000012 (stores: [blocked-cards-STATE-STORE-0000000010])
|
|
||||||
--> none
|
|
||||||
<-- KSTREAM-SOURCE-0000000011
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
StreamsBuilderFactoryBean present: true
|
|
||||||
auto-startup: false
|
|
||||||
running: false
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
src/broken/AmbiguousNullTombstone.java:9: error: reference to pipeInput is ambiguous
|
|
||||||
blocked.pipeInput("c5", null);
|
|
||||||
^
|
|
||||||
both method pipeInput(K,V) in TestInputTopic and method pipeInput(V,Instant) in TestInputTopic match
|
|
||||||
where K,V are type-variables:
|
|
||||||
K extends Object declared in class TestInputTopic
|
|
||||||
V extends Object declared in class TestInputTopic
|
|
||||||
1 error
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
|
||||||
<modelVersion>4.0.0</modelVersion>
|
|
||||||
|
|
||||||
<!-- Same parent as the other modules, so spring-kafka and kafka-streams versions are Boot-managed. -->
|
|
||||||
<parent>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-parent</artifactId>
|
|
||||||
<version>4.1.1</version>
|
|
||||||
<relativePath/>
|
|
||||||
</parent>
|
|
||||||
|
|
||||||
<groupId>com.ankurm</groupId>
|
|
||||||
<artifactId>kafka-streams-fraud</artifactId>
|
|
||||||
<version>1.0</version>
|
|
||||||
<packaging>jar</packaging>
|
|
||||||
|
|
||||||
<properties>
|
|
||||||
<java.version>25</java.version>
|
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
|
||||||
</properties>
|
|
||||||
|
|
||||||
<dependencies>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-kafka</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.apache.kafka</groupId>
|
|
||||||
<artifactId>kafka-streams</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.apache.kafka</groupId>
|
|
||||||
<artifactId>kafka-streams-test-utils</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
|
|
||||||
<build>
|
|
||||||
<plugins>
|
|
||||||
<plugin>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
|
||||||
</plugin>
|
|
||||||
</plugins>
|
|
||||||
</build>
|
|
||||||
</project>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Captures the real javac error for the null-tombstone mistake into output/07-ambiguous-null-tombstone.txt
|
|
||||||
set -uo pipefail
|
|
||||||
cd "$(dirname "$0")/.."
|
|
||||||
mvn -B -q dependency:build-classpath -Dmdep.outputFile=target/cp.txt >/dev/null
|
|
||||||
mkdir -p target/broken
|
|
||||||
javac -d target/broken -cp "$(cat target/cp.txt)" src/broken/AmbiguousNullTombstone.java 2>&1 \
|
|
||||||
| grep -v '^Picked up' > output/07-ambiguous-null-tombstone.txt
|
|
||||||
cat output/07-ambiguous-null-tombstone.txt
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Regenerates every file under output/ by running the test suite. No broker needed.
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$(dirname "$0")/.."
|
|
||||||
rm -f output/*.txt
|
|
||||||
mvn -B -q test
|
|
||||||
scripts/compile-broken.sh >/dev/null || true
|
|
||||||
ls output
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import org.apache.kafka.streams.TestInputTopic;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deliberately does not compile; it lives outside src/main and src/test so the build ignores it.
|
|
||||||
* scripts/compile-broken.sh runs javac on it and captures the compiler's own message.
|
|
||||||
*/
|
|
||||||
public class AmbiguousNullTombstone {
|
|
||||||
static void tombstone(TestInputTopic<String, String> blocked) {
|
|
||||||
blocked.pipeInput("c5", null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
package com.ankurm.streams;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Turns a transaction into a vector and scores it by cosine similarity to known-fraud vectors.
|
|
||||||
*
|
|
||||||
* <p>This is a hand-built feature vector, not a learned embedding: [log amount, sin(hour),
|
|
||||||
* cos(hour), is-crypto, is-electronics, is-grocery]. The topology around it would be identical if
|
|
||||||
* {@link #embed} called an embedding model; only this one method would change.
|
|
||||||
*/
|
|
||||||
public final class Embedder {
|
|
||||||
|
|
||||||
private Embedder() {}
|
|
||||||
|
|
||||||
/** Prototypes of confirmed fraud: large, late at night, in risky categories. */
|
|
||||||
static final List<double[]> FRAUD_PROTOTYPES = List.of(
|
|
||||||
embed(new Txn("proto", 4800, 3, "crypto")),
|
|
||||||
embed(new Txn("proto", 2900, 2, "electronics")));
|
|
||||||
|
|
||||||
public static double[] embed(Txn t) {
|
|
||||||
double angle = 2 * Math.PI * t.hour() / 24.0;
|
|
||||||
return new double[] {
|
|
||||||
Math.log10(Math.max(1, t.amount())) / 5.0,
|
|
||||||
Math.sin(angle),
|
|
||||||
Math.cos(angle),
|
|
||||||
"crypto".equals(t.category()) ? 1 : 0,
|
|
||||||
"electronics".equals(t.category()) ? 1 : 0,
|
|
||||||
"grocery".equals(t.category()) ? 1 : 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
public static double cosine(double[] a, double[] b) {
|
|
||||||
double dot = 0, na = 0, nb = 0;
|
|
||||||
for (int i = 0; i < a.length; i++) {
|
|
||||||
dot += a[i] * b[i];
|
|
||||||
na += a[i] * a[i];
|
|
||||||
nb += b[i] * b[i];
|
|
||||||
}
|
|
||||||
return (na == 0 || nb == 0) ? 0 : dot / (Math.sqrt(na) * Math.sqrt(nb));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Highest similarity to any fraud prototype, in [-1, 1]. */
|
|
||||||
public static double fraudScore(Txn t) {
|
|
||||||
double[] v = embed(t);
|
|
||||||
double best = -1;
|
|
||||||
for (double[] p : FRAUD_PROTOTYPES) {
|
|
||||||
best = Math.max(best, cosine(v, p));
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package com.ankurm.streams;
|
|
||||||
|
|
||||||
import org.apache.kafka.streams.StreamsBuilder;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
import org.springframework.kafka.annotation.EnableKafkaStreams;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Spring side is only this: {@code @EnableKafkaStreams} supplies a {@link StreamsBuilder}
|
|
||||||
* bean, and this method hands it to the plain-Java topology before the streams instance starts.
|
|
||||||
*/
|
|
||||||
@Configuration
|
|
||||||
@EnableKafkaStreams
|
|
||||||
public class FraudStreamsConfig {
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
public void wire(StreamsBuilder builder) {
|
|
||||||
FraudTopology.wire(builder);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
package com.ankurm.streams;
|
|
||||||
|
|
||||||
import org.apache.kafka.common.serialization.Serdes;
|
|
||||||
import org.apache.kafka.streams.StreamsBuilder;
|
|
||||||
import org.apache.kafka.streams.kstream.Consumed;
|
|
||||||
import org.apache.kafka.streams.kstream.Grouped;
|
|
||||||
import org.apache.kafka.streams.kstream.KStream;
|
|
||||||
import org.apache.kafka.streams.kstream.KTable;
|
|
||||||
import org.apache.kafka.streams.kstream.Materialized;
|
|
||||||
import org.apache.kafka.streams.kstream.Produced;
|
|
||||||
import org.apache.kafka.streams.kstream.TimeWindows;
|
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The topology, as plain Kafka Streams code with no Spring in it. That is deliberate: a topology
|
|
||||||
* that only needs a {@link StreamsBuilder} can be tested with TopologyTestDriver in milliseconds,
|
|
||||||
* with no broker.
|
|
||||||
*
|
|
||||||
* <pre>
|
|
||||||
* transactions --+--> mapValues(score) --filter(score >= 0.98)--------> alerts "SIMILAR:..."
|
|
||||||
* |
|
|
||||||
* +--> groupByKey --> 5-minute tumbling count (store txn-count-5m)
|
|
||||||
* | --filter(count == 4)------------------------> alerts "VELOCITY:..."
|
|
||||||
* |
|
|
||||||
* +--> join(blocked-cards KTable)-----------------------> alerts "BLOCKED:..."
|
|
||||||
* </pre>
|
|
||||||
*/
|
|
||||||
public final class FraudTopology {
|
|
||||||
|
|
||||||
public static final String IN = "transactions";
|
|
||||||
public static final String BLOCKED = "blocked-cards";
|
|
||||||
public static final String OUT = "alerts";
|
|
||||||
public static final String COUNT_STORE = "txn-count-5m";
|
|
||||||
public static final double SIMILARITY_THRESHOLD = 0.98;
|
|
||||||
public static final long VELOCITY_LIMIT = 4;
|
|
||||||
|
|
||||||
private FraudTopology() {}
|
|
||||||
|
|
||||||
public static void wire(StreamsBuilder b) {
|
|
||||||
KStream<String, String> raw = b.stream(IN, Consumed.with(Serdes.String(), Serdes.String()));
|
|
||||||
|
|
||||||
// 1. Similarity: score every transaction, keep the ones close to a known-fraud vector.
|
|
||||||
raw.mapValues(v -> Txn.decode(v))
|
|
||||||
.mapValues(t -> "SIMILAR:" + String.format("%.4f", Embedder.fraudScore(t)) + ":" + t.encode(),
|
|
||||||
org.apache.kafka.streams.kstream.Named.as("score"))
|
|
||||||
.filter((k, v) -> Double.parseDouble(v.split(":")[1]) >= SIMILARITY_THRESHOLD)
|
|
||||||
.to(OUT, Produced.with(Serdes.String(), Serdes.String()));
|
|
||||||
|
|
||||||
// 2. Velocity: count per card in 5-minute tumbling windows, alert on exactly the 4th.
|
|
||||||
raw.groupByKey(Grouped.with(Serdes.String(), Serdes.String()))
|
|
||||||
.windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofMinutes(5)))
|
|
||||||
.count(Materialized.as(COUNT_STORE))
|
|
||||||
.toStream()
|
|
||||||
.filter((w, c) -> c == VELOCITY_LIMIT)
|
|
||||||
.map((w, c) -> org.apache.kafka.streams.KeyValue.pair(w.key(), "VELOCITY:" + c + " in 5m"))
|
|
||||||
.to(OUT, Produced.with(Serdes.String(), Serdes.String()));
|
|
||||||
|
|
||||||
// 3. Blocklist: a KTable of blocked cards, joined to each transaction as it arrives.
|
|
||||||
KTable<String, String> blocked = b.table(BLOCKED, Consumed.with(Serdes.String(), Serdes.String()));
|
|
||||||
raw.join(blocked, (txn, reason) -> "BLOCKED:" + reason)
|
|
||||||
.to(OUT, Produced.with(Serdes.String(), Serdes.String()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package com.ankurm.streams;
|
|
||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
|
||||||
|
|
||||||
@SpringBootApplication
|
|
||||||
public class StreamsApplication {
|
|
||||||
public static void main(String[] args) {
|
|
||||||
SpringApplication.run(StreamsApplication.class, args);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package com.ankurm.streams;
|
|
||||||
|
|
||||||
/** A card transaction. */
|
|
||||||
public record Txn(String card, double amount, int hour, String category) {
|
|
||||||
|
|
||||||
/** Wire format: {@code card,amount,hour,category}. Plain text keeps the serde trivial to read. */
|
|
||||||
public String encode() {
|
|
||||||
return card + "," + amount + "," + hour + "," + category;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Txn decode(String s) {
|
|
||||||
String[] p = s.split(",", 4);
|
|
||||||
return new Txn(p[0], Double.parseDouble(p[1]), Integer.parseInt(p[2]), p[3]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
spring.kafka.bootstrap-servers=localhost:9092
|
|
||||||
spring.kafka.streams.application-id=fraud-streams
|
|
||||||
# Off by default here so the context can load without a broker; set true when you have one.
|
|
||||||
spring.kafka.streams.auto-startup=false
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package com.ankurm.streams;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
import org.springframework.context.ApplicationContext;
|
|
||||||
import org.springframework.kafka.config.StreamsBuilderFactoryBean;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
|
|
||||||
/** Proves the Spring wiring: the factory bean exists and holds a topology, without a broker. */
|
|
||||||
@SpringBootTest
|
|
||||||
class ContextLoadsTest {
|
|
||||||
|
|
||||||
@Autowired ApplicationContext ctx;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void streamsFactoryBeanIsWiredAndNotStarted() throws Exception {
|
|
||||||
var t = new Transcript("06-spring-context.txt");
|
|
||||||
var fb = ctx.getBean(StreamsBuilderFactoryBean.class);
|
|
||||||
t.line("StreamsBuilderFactoryBean present: " + (fb != null));
|
|
||||||
t.line("auto-startup: " + fb.isAutoStartup());
|
|
||||||
t.line("running: " + fb.isRunning());
|
|
||||||
t.write();
|
|
||||||
assertFalse(fb.isRunning());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
package com.ankurm.streams;
|
|
||||||
|
|
||||||
import org.apache.kafka.common.serialization.StringDeserializer;
|
|
||||||
import org.apache.kafka.common.serialization.StringSerializer;
|
|
||||||
import org.apache.kafka.streams.StreamsBuilder;
|
|
||||||
import org.apache.kafka.streams.StreamsConfig;
|
|
||||||
import org.apache.kafka.streams.TestInputTopic;
|
|
||||||
import org.apache.kafka.streams.TestOutputTopic;
|
|
||||||
import org.apache.kafka.streams.Topology;
|
|
||||||
import org.apache.kafka.streams.TopologyTestDriver;
|
|
||||||
import org.apache.kafka.streams.state.WindowStore;
|
|
||||||
import org.apache.kafka.streams.state.KeyValueIterator;
|
|
||||||
import org.junit.jupiter.api.AfterEach;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Properties;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
|
|
||||||
class FraudTopologyTest {
|
|
||||||
|
|
||||||
private TopologyTestDriver driver;
|
|
||||||
private TestInputTopic<String, String> txns;
|
|
||||||
private TestInputTopic<String, String> blocked;
|
|
||||||
private TestOutputTopic<String, String> alerts;
|
|
||||||
private Topology topology;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
StreamsBuilder b = new StreamsBuilder();
|
|
||||||
FraudTopology.wire(b);
|
|
||||||
topology = b.build();
|
|
||||||
Properties p = new Properties();
|
|
||||||
p.put(StreamsConfig.APPLICATION_ID_CONFIG, "fraud-test");
|
|
||||||
p.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "dummy:1");
|
|
||||||
driver = new TopologyTestDriver(topology, p, Instant.parse("2026-10-14T10:00:00Z"));
|
|
||||||
txns = driver.createInputTopic(FraudTopology.IN, new StringSerializer(), new StringSerializer());
|
|
||||||
blocked = driver.createInputTopic(FraudTopology.BLOCKED, new StringSerializer(), new StringSerializer());
|
|
||||||
alerts = driver.createOutputTopic(FraudTopology.OUT, new StringDeserializer(), new StringDeserializer());
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterEach
|
|
||||||
void tearDown() { driver.close(); }
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void similarityScoresSeparateFraudShapedFromOrdinary() throws Exception {
|
|
||||||
var t = new Transcript("01-similarity-scores.txt");
|
|
||||||
var samples = List.of(
|
|
||||||
new Txn("c1", 4700, 3, "crypto"),
|
|
||||||
new Txn("c1", 3000, 2, "electronics"),
|
|
||||||
new Txn("c1", 45, 18, "grocery"),
|
|
||||||
new Txn("c1", 120, 13, "electronics"),
|
|
||||||
new Txn("c1", 2500, 14, "crypto"));
|
|
||||||
for (Txn x : samples) {
|
|
||||||
t.line(String.format("%-40s score=%.4f", x.encode(), Embedder.fraudScore(x)));
|
|
||||||
}
|
|
||||||
t.line("threshold: " + FraudTopology.SIMILARITY_THRESHOLD);
|
|
||||||
t.write();
|
|
||||||
|
|
||||||
assertTrue(Embedder.fraudScore(samples.get(0)) >= FraudTopology.SIMILARITY_THRESHOLD);
|
|
||||||
assertTrue(Embedder.fraudScore(samples.get(1)) >= FraudTopology.SIMILARITY_THRESHOLD);
|
|
||||||
assertTrue(Embedder.fraudScore(samples.get(2)) < FraudTopology.SIMILARITY_THRESHOLD);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void similarTransactionsRaiseAlertsOrdinaryOnesDoNot() throws Exception {
|
|
||||||
var t = new Transcript("02-similarity-alerts.txt");
|
|
||||||
txns.pipeInput("c1", new Txn("c1", 4700, 3, "crypto").encode());
|
|
||||||
txns.pipeInput("c2", new Txn("c2", 45, 18, "grocery").encode());
|
|
||||||
var out = alerts.readKeyValuesToList();
|
|
||||||
out.forEach(kv -> t.line("alerts <- " + kv.key + " " + kv.value));
|
|
||||||
t.line("alerts emitted: " + out.size());
|
|
||||||
t.write();
|
|
||||||
|
|
||||||
assertEquals(1, out.size());
|
|
||||||
assertEquals("c1", out.get(0).key);
|
|
||||||
assertTrue(out.get(0).value.startsWith("SIMILAR:"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void velocityAlertFiresOnTheFourthTransactionInAFiveMinuteWindowOnly() throws Exception {
|
|
||||||
var t = new Transcript("03-velocity-window.txt");
|
|
||||||
Instant start = Instant.parse("2026-10-14T10:00:30Z");
|
|
||||||
for (int i = 0; i < 6; i++) {
|
|
||||||
txns.pipeInput("c9", new Txn("c9", 20, 12, "grocery").encode(), start.plusSeconds(i * 20L));
|
|
||||||
}
|
|
||||||
var out = alerts.readKeyValuesToList();
|
|
||||||
out.forEach(kv -> t.line("alerts <- " + kv.key + " " + kv.value));
|
|
||||||
t.line("alerts emitted for 6 transactions in 2 minutes: " + out.size());
|
|
||||||
|
|
||||||
// The same count in the next window starts again from one.
|
|
||||||
txns.pipeInput("c9", new Txn("c9", 20, 12, "grocery").encode(), start.plusSeconds(600));
|
|
||||||
t.line("alerts after one more transaction 10 minutes later: " + alerts.readKeyValuesToList().size());
|
|
||||||
|
|
||||||
WindowStore<String, Long> store = driver.getWindowStore(FraudTopology.COUNT_STORE);
|
|
||||||
try (KeyValueIterator<org.apache.kafka.streams.kstream.Windowed<String>, Long> it =
|
|
||||||
store.fetchAll(Instant.parse("2026-10-14T09:00:00Z"), Instant.parse("2026-10-14T11:00:00Z"))) {
|
|
||||||
while (it.hasNext()) {
|
|
||||||
var kv = it.next();
|
|
||||||
t.line("state store window " + kv.key.window().startTime() + " count=" + kv.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.write();
|
|
||||||
|
|
||||||
assertEquals(1, out.size());
|
|
||||||
assertEquals("VELOCITY:4 in 5m", out.get(0).value);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void blocklistTableJoinsEachTransactionAsItArrives() throws Exception {
|
|
||||||
var t = new Transcript("04-blocklist-table-join.txt");
|
|
||||||
txns.pipeInput("c5", new Txn("c5", 15, 12, "grocery").encode());
|
|
||||||
t.line("before blocking, alerts: " + alerts.readKeyValuesToList().size());
|
|
||||||
|
|
||||||
blocked.pipeInput("c5", "reported stolen");
|
|
||||||
txns.pipeInput("c5", new Txn("c5", 15, 12, "grocery").encode());
|
|
||||||
var out = alerts.readKeyValuesToList();
|
|
||||||
out.forEach(kv -> t.line("alerts <- " + kv.key + " " + kv.value));
|
|
||||||
|
|
||||||
blocked.pipeInput("c5", (String) null); // tombstone removes the card from the table
|
|
||||||
txns.pipeInput("c5", new Txn("c5", 15, 12, "grocery").encode());
|
|
||||||
t.line("after the tombstone, alerts: " + alerts.readKeyValuesToList().size());
|
|
||||||
t.write();
|
|
||||||
|
|
||||||
assertEquals(1, out.size());
|
|
||||||
assertEquals("BLOCKED:reported stolen", out.get(0).value);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void describeTheTopology() throws Exception {
|
|
||||||
var t = new Transcript("05-topology-description.txt");
|
|
||||||
t.line(topology.describe().toString().trim());
|
|
||||||
t.write();
|
|
||||||
assertTrue(topology.describe().toString().contains(FraudTopology.COUNT_STORE));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package com.ankurm.streams;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/** Collects lines and writes them to output/NN-name.txt, so the test IS the transcript. */
|
|
||||||
final class Transcript {
|
|
||||||
private final List<String> lines = new ArrayList<>();
|
|
||||||
private final String file;
|
|
||||||
|
|
||||||
Transcript(String file) { this.file = file; }
|
|
||||||
|
|
||||||
void line(String s) { lines.add(s); }
|
|
||||||
|
|
||||||
void write() throws IOException {
|
|
||||||
Path dir = Path.of("output");
|
|
||||||
Files.createDirectories(dir);
|
|
||||||
Files.write(dir.resolve(file), lines);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
target/
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# kafka4-migration: Spring Kafka 3 to 4 on Spring Boot 3.5 to 4.1
|
|
||||||
|
|
||||||
Companion code for the ankurm.com post "Spring Kafka 3 to 4 Migration Guide".
|
|
||||||
|
|
||||||
| Folder | What it is |
|
|
||||||
|---|---|
|
|
||||||
| `legacy/` | Producer, `@KafkaListener` and an `@EmbeddedKafka` test on Boot 3.5.16 / Spring Kafka 3.3.16 / kafka-clients 3.9.2 (JDK 21), using the Jackson 2 `JsonSerializer` |
|
|
||||||
| `migrated/` | The same app on Boot 4.1.1 / Spring Kafka 4.1.1 / kafka-clients 4.2.1 (JDK 25), using `spring-boot-starter-kafka` and `JacksonJsonSerializer` |
|
|
||||||
| `scripts/run-all.sh` | Builds the intermediate stages from `legacy/` and regenerates every file in `output/` |
|
|
||||||
| `output/` | Transcripts for each stage |
|
|
||||||
|
|
||||||
Spring Kafka 4.1.1 is the latest GA; 4.2.0-M1 and 4.2.0-M2 are milestones and are not tested here.
|
|
||||||
No external broker is needed: the tests use `@EmbeddedKafka`. Tested on 28 September 2026.
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<?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.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-parent</artifactId>
|
|
||||||
<version>3.5.16</version>
|
|
||||||
<relativePath/>
|
|
||||||
</parent>
|
|
||||||
<groupId>com.ankurm</groupId>
|
|
||||||
<artifactId>kafka-legacy</artifactId>
|
|
||||||
<version>1.0.0</version>
|
|
||||||
<properties><java.version>21</java.version></properties>
|
|
||||||
<dependencies>
|
|
||||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter</artifactId></dependency>
|
|
||||||
<dependency><groupId>org.springframework.kafka</groupId><artifactId>spring-kafka</artifactId></dependency>
|
|
||||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-json</artifactId></dependency>
|
|
||||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
|
|
||||||
<dependency><groupId>org.springframework.kafka</groupId><artifactId>spring-kafka-test</artifactId><scope>test</scope></dependency>
|
|
||||||
</dependencies>
|
|
||||||
</project>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package com.ankurm.kafka;
|
|
||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
|
||||||
|
|
||||||
@SpringBootApplication
|
|
||||||
public class App {
|
|
||||||
public static void main(String[] args) { SpringApplication.run(App.class, args); }
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
package com.ankurm.kafka;
|
|
||||||
|
|
||||||
public record OrderEvent(String id, int qty) { }
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package com.ankurm.kafka;
|
|
||||||
|
|
||||||
import java.util.concurrent.CopyOnWriteArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import org.springframework.kafka.annotation.KafkaListener;
|
|
||||||
import org.springframework.kafka.support.KafkaHeaders;
|
|
||||||
import org.springframework.messaging.handler.annotation.Header;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
@Component
|
|
||||||
public class OrderListener {
|
|
||||||
public final List<String> received = new CopyOnWriteArrayList<>();
|
|
||||||
|
|
||||||
@KafkaListener(topics = "orders")
|
|
||||||
void on(OrderEvent event, @Header(KafkaHeaders.RECEIVED_KEY) String key) {
|
|
||||||
received.add("key=" + key + " id=" + event.id() + " qty=" + event.qty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package com.ankurm.kafka;
|
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import org.springframework.kafka.support.serializer.JsonSerializer;
|
|
||||||
|
|
||||||
/** Serialises one event with the JSON serializer, using only the runtime classpath (no test jars). */
|
|
||||||
public class SerdeProbe {
|
|
||||||
public static void main(String[] args) {
|
|
||||||
try (JsonSerializer<OrderEvent> s = new JsonSerializer<>()) {
|
|
||||||
byte[] bytes = s.serialize("orders", new OrderEvent("o-1", 3));
|
|
||||||
System.out.println("SERIALIZED " + new String(bytes, StandardCharsets.UTF_8));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
spring.kafka.consumer.group-id=orders-group
|
|
||||||
spring.kafka.consumer.auto-offset-reset=earliest
|
|
||||||
spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer
|
|
||||||
spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer
|
|
||||||
spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer
|
|
||||||
spring.kafka.consumer.value-deserializer=org.springframework.kafka.support.serializer.JsonDeserializer
|
|
||||||
spring.kafka.consumer.properties.spring.json.trusted.packages=com.ankurm.kafka
|
|
||||||
spring.kafka.consumer.properties.spring.json.value.default.type=com.ankurm.kafka.OrderEvent
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package com.ankurm.kafka;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.awaitility.Awaitility.await;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
import org.springframework.kafka.core.KafkaTemplate;
|
|
||||||
import org.springframework.kafka.test.EmbeddedKafkaBroker;
|
|
||||||
import org.springframework.kafka.test.context.EmbeddedKafka;
|
|
||||||
|
|
||||||
@SpringBootTest(properties = "spring.kafka.bootstrap-servers=${spring.embedded.kafka.brokers}")
|
|
||||||
@EmbeddedKafka(topics = "orders", partitions = 1)
|
|
||||||
class OrderFlowTest {
|
|
||||||
@Autowired KafkaTemplate<String, OrderEvent> template;
|
|
||||||
@Autowired OrderListener listener;
|
|
||||||
@Autowired EmbeddedKafkaBroker broker;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void sendAndReceive() {
|
|
||||||
template.send("orders", "k1", new OrderEvent("o-1", 3));
|
|
||||||
await().untilAsserted(() -> assertThat(listener.received).hasSize(1));
|
|
||||||
System.out.println("RESULT " + listener.received.get(0));
|
|
||||||
System.out.println("BROKERS " + broker.getBrokersAsString().replaceAll(":\\d+", ":PORT"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<?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.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-parent</artifactId>
|
|
||||||
<version>4.1.1</version>
|
|
||||||
<relativePath/>
|
|
||||||
</parent>
|
|
||||||
<groupId>com.ankurm</groupId>
|
|
||||||
<artifactId>kafka-migrated</artifactId>
|
|
||||||
<version>1.0.0</version>
|
|
||||||
<properties><java.version>25</java.version></properties>
|
|
||||||
<dependencies>
|
|
||||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter</artifactId></dependency>
|
|
||||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-kafka</artifactId></dependency>
|
|
||||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jackson</artifactId></dependency>
|
|
||||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
|
|
||||||
<dependency><groupId>org.springframework.kafka</groupId><artifactId>spring-kafka-test</artifactId><scope>test</scope></dependency>
|
|
||||||
</dependencies>
|
|
||||||
</project>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package com.ankurm.kafka;
|
|
||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
|
||||||
|
|
||||||
@SpringBootApplication
|
|
||||||
public class App {
|
|
||||||
public static void main(String[] args) { SpringApplication.run(App.class, args); }
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
package com.ankurm.kafka;
|
|
||||||
|
|
||||||
public record OrderEvent(String id, int qty) { }
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package com.ankurm.kafka;
|
|
||||||
|
|
||||||
import java.util.concurrent.CopyOnWriteArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import org.springframework.kafka.annotation.KafkaListener;
|
|
||||||
import org.springframework.kafka.support.KafkaHeaders;
|
|
||||||
import org.springframework.messaging.handler.annotation.Header;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
@Component
|
|
||||||
public class OrderListener {
|
|
||||||
public final List<String> received = new CopyOnWriteArrayList<>();
|
|
||||||
|
|
||||||
@KafkaListener(topics = "orders")
|
|
||||||
void on(OrderEvent event, @Header(KafkaHeaders.RECEIVED_KEY) String key) {
|
|
||||||
received.add("key=" + key + " id=" + event.id() + " qty=" + event.qty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package com.ankurm.kafka;
|
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import org.springframework.kafka.support.serializer.JacksonJsonSerializer;
|
|
||||||
|
|
||||||
/** Serialises one event with the JSON serializer, using only the runtime classpath (no test jars). */
|
|
||||||
public class SerdeProbe {
|
|
||||||
public static void main(String[] args) {
|
|
||||||
try (JacksonJsonSerializer<OrderEvent> s = new JacksonJsonSerializer<>()) {
|
|
||||||
byte[] bytes = s.serialize("orders", new OrderEvent("o-1", 3));
|
|
||||||
System.out.println("SERIALIZED " + new String(bytes, StandardCharsets.UTF_8));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
spring.kafka.consumer.group-id=orders-group
|
|
||||||
spring.kafka.consumer.auto-offset-reset=earliest
|
|
||||||
spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer
|
|
||||||
spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JacksonJsonSerializer
|
|
||||||
spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer
|
|
||||||
spring.kafka.consumer.value-deserializer=org.springframework.kafka.support.serializer.JacksonJsonDeserializer
|
|
||||||
spring.kafka.consumer.properties.spring.json.trusted.packages=com.ankurm.kafka
|
|
||||||
spring.kafka.consumer.properties.spring.json.value.default.type=com.ankurm.kafka.OrderEvent
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package com.ankurm.kafka;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.awaitility.Awaitility.await;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
import org.springframework.kafka.core.KafkaTemplate;
|
|
||||||
import org.springframework.kafka.test.EmbeddedKafkaBroker;
|
|
||||||
import org.springframework.kafka.test.context.EmbeddedKafka;
|
|
||||||
|
|
||||||
@SpringBootTest(properties = "spring.kafka.bootstrap-servers=${spring.embedded.kafka.brokers}")
|
|
||||||
@EmbeddedKafka(topics = "orders", partitions = 1)
|
|
||||||
class OrderFlowTest {
|
|
||||||
@Autowired KafkaTemplate<String, OrderEvent> template;
|
|
||||||
@Autowired OrderListener listener;
|
|
||||||
@Autowired EmbeddedKafkaBroker broker;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void sendAndReceive() {
|
|
||||||
template.send("orders", "k1", new OrderEvent("o-1", 3));
|
|
||||||
await().untilAsserted(() -> assertThat(listener.received).hasSize(1));
|
|
||||||
System.out.println("RESULT " + listener.received.get(0));
|
|
||||||
System.out.println("BROKERS " + broker.getBrokersAsString().replaceAll(":\\d+", ":PORT"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
$ mvn test # Boot 3.5.16, JDK 21
|
|
||||||
RESULT key=k1 id=o-1 qty=3
|
|
||||||
BROKERS 127.0.0.1:PORT
|
|
||||||
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 7.853 s -- in com.ankurm.kafka.OrderFlowTest
|
|
||||||
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
|
|
||||||
[INFO] BUILD SUCCESS
|
|
||||||
|
|
||||||
org.apache.kafka:kafka-clients:jar:3.9.2:compile
|
|
||||||
org.springframework.kafka:spring-kafka:jar:3.3.16:compile
|
|
||||||
|
|
||||||
$ java -cp target/classes:<runtime classpath> SerdeProbe
|
|
||||||
SERIALIZED {"id":"o-1","qty":3}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
$ mvn test # only the Boot version changed to 4.1.1
|
|
||||||
[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 5.728 s <<< FAILURE! -- in com.ankurm.kafka.OrderFlowTest
|
|
||||||
[ERROR] OrderFlowTest.sendAndReceive ? UnsatisfiedDependency Error creating bean with name 'com.ankurm.kafka.OrderFlowTest': Unsatisfied dependency expressed through field 'template': No qualifying bean of type 'org.springframework.kafka.core.KafkaTemplate<java.lang.String, com.ankurm.kafka.OrderEvent>' available: expected at
|
|
||||||
[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0
|
|
||||||
[INFO] BUILD FAILURE
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
$ mvn test # spring-kafka replaced by spring-boot-starter-kafka; nothing else changed
|
|
||||||
RESULT key=k1 id=o-1 qty=3
|
|
||||||
BROKERS localhost:PORT
|
|
||||||
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 9.853 s -- in com.ankurm.kafka.OrderFlowTest
|
|
||||||
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
|
|
||||||
[INFO] BUILD SUCCESS
|
|
||||||
|
|
||||||
org.apache.kafka:kafka-clients:jar:4.2.1:compile
|
|
||||||
org.springframework.boot:spring-boot-kafka:jar:4.1.1:compile
|
|
||||||
org.springframework.kafka:spring-kafka:jar:4.1.1:compile
|
|
||||||
|
|
||||||
$ mvn dependency:list | grep jackson # runtime scope, then test scope
|
|
||||||
runtime: tools.jackson.core:jackson-core:jar:3.1.5:compile
|
|
||||||
runtime: tools.jackson.core:jackson-databind:jar:3.1.5:compile
|
|
||||||
test-only: com.fasterxml.jackson.core:jackson-databind:jar:2.21.5:test
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
$ compile and run SerdeProbe on the runtime classpath only (JsonSerializer from Spring Kafka 4.1.1)
|
|
||||||
[ERROR] class file for com.fasterxml.jackson.core.type.TypeReference not found
|
|
||||||
[ERROR] s2/src/main/java/com/ankurm/kafka/SerdeProbe.java:[9,45] cannot access com.fasterxml.jackson.core.type.TypeReference
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
$ mvn test # final migrated project (JacksonJsonSerializer, spring-boot-starter-jackson)
|
|
||||||
RESULT key=k1 id=o-1 qty=3
|
|
||||||
BROKERS localhost:PORT
|
|
||||||
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 8.478 s -- in com.ankurm.kafka.OrderFlowTest
|
|
||||||
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
|
|
||||||
[INFO] BUILD SUCCESS
|
|
||||||
|
|
||||||
org.apache.kafka:kafka-clients:jar:4.2.1:compile
|
|
||||||
org.springframework.boot:spring-boot-kafka:jar:4.1.1:compile
|
|
||||||
org.springframework.kafka:spring-kafka:jar:4.1.1:compile
|
|
||||||
|
|
||||||
$ java -cp target/classes:<runtime classpath> SerdeProbe
|
|
||||||
SERIALIZED {"id":"o-1","qty":3}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
$ jar tf spring-kafka-4.1.1.jar | grep support/serializer/(Json|Jackson)
|
|
||||||
JacksonJsonDeserializer.class JacksonJsonSerde.class JacksonJsonSerializer.class JacksonJsonTypeResolver.class JsonDeserializer.class JsonSerde.class JsonSerializer.class JsonTypeResolver.class
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Regenerates output/*.txt. Needs Maven, JDK 21 (J21), JDK 25 (J25) and Maven Central access.
|
|
||||||
set -u
|
|
||||||
HERE=$(cd "$(dirname "$0")/.." && pwd); OUT=$HERE/output; W=${WORK:-/tmp/k4-work}
|
|
||||||
J21=${J21:-/usr/lib/jvm/java-21-openjdk-amd64}; J25=${J25:-/tmp/tools/jdk-25.0.4.1+1}; MVN=${MVN:-mvn}
|
|
||||||
mkdir -p "$OUT"; rm -rf "$W"; mkdir -p "$W"
|
|
||||||
mv_() { local jh=$1 d=$2; shift 2; (cd "$d" && JAVA_HOME=$jh $MVN -B "$@" 2>&1 | grep -v JAVA_TOOL_OPTIONS | sed "s#$W/##g"); }
|
|
||||||
keep='^(RESULT|BROKERS)|Tests run:.*Fail|BUILD|ERROR.*(cannot access|No qualifying)'
|
|
||||||
stage() { rm -rf "$W/$1"; cp -r "$HERE/$2" "$W/$1"; rm -rf "$W/$1/target"; }
|
|
||||||
probe() { # probe <javahome> <dir>: compile and run SerdeProbe on the RUNTIME classpath only
|
|
||||||
local jh=$1 d=$2
|
|
||||||
(cd "$d" && JAVA_HOME=$jh $MVN -B -q compile dependency:build-classpath -Dmdep.includeScope=runtime -Dmdep.outputFile="$W/cp.txt" 2>&1 | grep -v JAVA_TOOL_OPTIONS | grep -E 'ERROR.*(cannot access|class file)' | sed "s#$W/##g" | sort -u
|
|
||||||
[ -f "$W/cp.txt" ] && JAVA_HOME=$jh $jh/bin/java -cp target/classes:$(cat "$W/cp.txt") com.ankurm.kafka.SerdeProbe 2>&1 | grep -v JAVA_TOOL_OPTIONS | head -3); rm -f "$W/cp.txt"; }
|
|
||||||
versions() { (cd "$2" && JAVA_HOME=$1 $MVN -B dependency:list 2>&1 | grep -E 'org.springframework.kafka:spring-kafka:|org.springframework.boot:spring-boot-kafka:|org.apache.kafka:kafka-clients:jar:[0-9]' | sed 's/^\[INFO\] *//; s/ -- module.*//' | sort -u); }
|
|
||||||
|
|
||||||
stage s0 legacy
|
|
||||||
{ echo '$ mvn test # Boot 3.5.16, JDK 21'; mv_ "$J21" "$W/s0" test | grep -E "$keep"
|
|
||||||
echo; versions "$J21" "$W/s0"
|
|
||||||
echo; echo '$ java -cp target/classes:<runtime classpath> SerdeProbe'; probe "$J21" "$W/s0"; } > "$OUT/01-legacy-kafka3.txt"
|
|
||||||
|
|
||||||
stage s1 legacy; rm "$W/s1/src/main/java/com/ankurm/kafka/SerdeProbe.java"; sed -i 's/3.5.16/4.1.1/; s/<java.version>21/<java.version>25/' "$W/s1/pom.xml"
|
|
||||||
{ echo '$ mvn test # only the Boot version changed to 4.1.1'; mv_ "$J25" "$W/s1" test | grep -E "$keep" | cut -c1-330; } > "$OUT/02-boot4-no-starter.txt"
|
|
||||||
|
|
||||||
cp -r "$W/s1" "$W/s2"; rm -rf "$W/s2/target"
|
|
||||||
sed -i 's#<groupId>org.springframework.kafka</groupId><artifactId>spring-kafka</artifactId>#<groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-kafka</artifactId>#' "$W/s2/pom.xml"
|
|
||||||
{ echo '$ mvn test # spring-kafka replaced by spring-boot-starter-kafka; nothing else changed'; mv_ "$J25" "$W/s2" test | grep -E "$keep"
|
|
||||||
echo; versions "$J25" "$W/s2"
|
|
||||||
echo; echo '$ mvn dependency:list | grep jackson # runtime scope, then test scope'
|
|
||||||
(cd "$W/s2" && JAVA_HOME=$J25 $MVN -B dependency:list -DincludeScope=runtime 2>&1 | grep -E 'jackson-(core|databind)' | sed 's/^\[INFO\] *//; s/ -- module.*//' | sort -u | sed 's/^/runtime: /'
|
|
||||||
JAVA_HOME=$J25 $MVN -B dependency:list -DexcludeScope=runtime 2>&1 | grep -E 'com.fasterxml.jackson.core:jackson-databind' | sed 's/^\[INFO\] *//; s/ -- module.*//' | sort -u | sed 's/^/test-only: /'); } > "$OUT/03-starter-added-tests-pass.txt"
|
|
||||||
|
|
||||||
cp "$HERE/legacy/src/main/java/com/ankurm/kafka/SerdeProbe.java" "$W/s2/src/main/java/com/ankurm/kafka/"
|
|
||||||
{ echo '$ compile and run SerdeProbe on the runtime classpath only (JsonSerializer from Spring Kafka 4.1.1)'; probe "$J25" "$W/s2"; } > "$OUT/04-runtime-classpath-has-no-jackson2.txt"
|
|
||||||
|
|
||||||
stage m migrated
|
|
||||||
{ echo '$ mvn test # final migrated project (JacksonJsonSerializer, spring-boot-starter-jackson)'; mv_ "$J25" "$W/m" test | grep -E "$keep"
|
|
||||||
echo; versions "$J25" "$W/m"
|
|
||||||
echo; echo '$ java -cp target/classes:<runtime classpath> SerdeProbe'; probe "$J25" "$W/m"; } > "$OUT/05-migrated.txt"
|
|
||||||
|
|
||||||
# 06 API facts from the Spring Kafka 4.1.1 jar
|
|
||||||
J=$HOME/.m2/repository/org/springframework/kafka/spring-kafka/4.1.1/spring-kafka-4.1.1.jar
|
|
||||||
{ echo '$ jar tf spring-kafka-4.1.1.jar | grep support/serializer/(Json|Jackson)'
|
|
||||||
env -u JAVA_TOOL_OPTIONS $J25/bin/jar tf "$J" | grep -E 'support/serializer/(Json|Jackson)' | sed 's#.*/##' | sort | tr '\n' ' '; echo; } > "$OUT/06-serializer-classes.txt"
|
|
||||||
echo done
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
# `protocol-comparison` — RSocket vs gRPC vs WebSocket, same use case, measured
|
|
||||||
|
|
||||||
Companion project for
|
|
||||||
[**RSocket vs gRPC vs WebSocket on Spring Boot 4.1: When Each One Wins**](https://ankurm.com/rsocket-vs-grpc-vs-websocket-spring-boot-4-1/)
|
|
||||||
on ankurm.com.
|
|
||||||
|
|
||||||
**One application serves the same two operations over all three protocols.** Same JVM, same heap,
|
|
||||||
same data generator, same five fields — so a difference in the numbers is a difference in the
|
|
||||||
transports rather than in three separately-written demos.
|
|
||||||
[`./scripts/run-all.sh`](scripts/run-all.sh) re-measures everything under
|
|
||||||
[`docs/output/`](docs/output/).
|
|
||||||
|
|
||||||
## Versions
|
|
||||||
|
|
||||||
| | Version | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| JDK | 25 (Temurin 25.0.4.1+1) | current LTS |
|
|
||||||
| Spring Boot | 4.1.1 | latest GA of the 4.1 line |
|
|
||||||
| Spring Framework | 7.0.9 | Boot-managed |
|
|
||||||
| grpc-java | **1.83.1** | Boot-managed. Boot 4.1.0 pinned 1.80.0 — it moved |
|
|
||||||
| protobuf-java | **4.35.1** | Boot-managed. Boot 4.1.0 pinned 4.34.2 |
|
|
||||||
| rsocket-java | 1.1.5 | Boot-managed |
|
|
||||||
| Spring gRPC | 1.1.1 | the programming model; separate version from Boot's |
|
|
||||||
| Jackson | 3.1.5 (`tools.jackson`) | plus `jackson-dataformat-cbor`, RSocket's default |
|
|
||||||
|
|
||||||
Read from `repo1.maven.org/.../maven-metadata.xml` and Boot's own `spring-boot-dependencies` POM.
|
|
||||||
|
|
||||||
## Quickstart
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/run-all.sh # six tests, then re-measure docs/output/
|
|
||||||
mvn test # the same without the capture
|
|
||||||
mvn spring-boot:run # gRPC 9090, RSocket 7000, WebSocket ws://localhost:8080/quotes
|
|
||||||
```
|
|
||||||
|
|
||||||
Nothing to install: `protoc` and the gRPC codegen plugin resolve as Maven artifacts for your OS
|
|
||||||
and architecture, and all three servers start inside the test JVM.
|
|
||||||
|
|
||||||
## The operations
|
|
||||||
|
|
||||||
| | gRPC | RSocket route | WebSocket command |
|
|
||||||
|---|---|---|---|
|
|
||||||
| one quote | `QuoteService/GetQuote` | `quote` | `QUOTE <symbol>` |
|
|
||||||
| N quotes | `QuoteService/StreamQuotes` | `quotes` | `STREAM <symbol> <n> <delayMs>` |
|
|
||||||
| unbounded | `QuoteService/StreamUnbounded` | `quotes.unbounded` | `UNBOUNDED <symbol>` |
|
|
||||||
| same, `isReady()`-gated | `QuoteService/StreamUnboundedReady` | — | — |
|
|
||||||
| server-side produced count | `QuoteService/Produced` | `produced` | `PRODUCED` |
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
1. [The same use case, three ways](docs/01-three-protocols.md)
|
|
||||||
2. [How they measure — and why a loopback benchmark flatters the wrong one](docs/02-benchmarks.md)
|
|
||||||
3. [Payload: the one comparison that needs no benchmark](docs/03-payload.md)
|
|
||||||
4. [Back-pressure: the measurement that decides it](docs/04-backpressure.md)
|
|
||||||
5. [Choosing](docs/05-choosing.md)
|
|
||||||
|
|
||||||
## Captured output
|
|
||||||
|
|
||||||
| File | What it shows |
|
|
||||||
|---|---|
|
|
||||||
| [`payload-sizes.txt`](docs/output/payload-sizes.txt) | one message as protobuf, JSON and CBOR, in hex |
|
|
||||||
| [`request-response.txt`](docs/output/request-response.txt) | 5 000 sequential calls, p50/p99/mean per protocol |
|
|
||||||
| [`stream-throughput.txt`](docs/output/stream-throughput.txt) | 50 000 messages on one connection |
|
|
||||||
| [`backpressure.txt`](docs/output/backpressure.txt) | how many the server produced for a client that wanted 100 |
|
|
||||||
| [`grpc-isready.txt`](docs/output/grpc-isready.txt) | the documented gRPC fix, tested — and it did not work here |
|
|
||||||
| [`rsocket-codecs.txt`](docs/output/rsocket-codecs.txt) | what Boot configures for RSocket, and why CBOR wins |
|
|
||||||
| [`tests.txt`](docs/output/tests.txt) | 6 tests |
|
|
||||||
|
|
||||||
## Read the benchmark honestly
|
|
||||||
|
|
||||||
Client and server are the same process on one loopback interface, two cores. **There is no
|
|
||||||
network.** That deletes protobuf's size advantage, and it flatters whichever protocol does the
|
|
||||||
least work per message — which is why raw WebSocket wins both speed benchmarks and why that is
|
|
||||||
not the recommendation. Treat the ratios as indicative and the absolutes as meaningless off this
|
|
||||||
box.
|
|
||||||
|
|
||||||
The one result that *does* transfer is the back-pressure table, because it is a property of the
|
|
||||||
protocols rather than of the link.
|
|
||||||
|
|
||||||
## Four things this module exists to prove
|
|
||||||
|
|
||||||
1. **RSocket's `request(n)` is real and the others have no equivalent.** For a client that took
|
|
||||||
100 messages then went quiet for two seconds, the servers produced **100**, **1 346 233** and
|
|
||||||
**311 617** respectively.
|
|
||||||
2. **`ServerCallStreamObserver.isReady()` did not bound a gRPC producer here.** The documented fix
|
|
||||||
was tested; the transcript is committed as measured, including its instability.
|
|
||||||
3. **Raw WebSocket is the fastest and has the worst tail.** p50 of 124 µs against a p99 of
|
|
||||||
1 448 µs — a 12x spread, where gRPC's is 3.5x.
|
|
||||||
4. **CBOR keeps the field names, and it wins by list order.** It is binary JSON, not a schema
|
|
||||||
format: 67 bytes against protobuf's 35 and JSON's 83. Boot puts `JacksonCborEncoder` ahead of
|
|
||||||
`JacksonJsonEncoder`, and a bare `RSocketStrategies.create()` has neither.
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
# 1. The same use case, three ways
|
|
||||||
|
|
||||||
Next: [2. How they measure](02-benchmarks.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
One application in this module serves the same two operations over gRPC, RSocket and a raw
|
|
||||||
WebSocket: fetch one quote, and stream N quotes. Same JVM, same heap, same
|
|
||||||
[`QuoteSource`](../src/main/java/com/ankurm/protocols/QuoteSource.java), same five fields.
|
|
||||||
|
|
||||||
That constraint is the point. Cross-protocol benchmarks published on the internet almost always
|
|
||||||
compare three different applications, and end up measuring three different serialisation
|
|
||||||
libraries, three JIT states and three thread pools.
|
|
||||||
|
|
||||||
## What each one is, in one sentence
|
|
||||||
|
|
||||||
| | gRPC | RSocket | Raw WebSocket |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Transport | HTTP/2 | TCP, WebSocket, or others | HTTP/1.1 Upgrade |
|
|
||||||
| Schema | `.proto`, mandatory, code-generated | none required | none |
|
|
||||||
| Encoding here | protobuf | CBOR (Spring's default) | JSON |
|
|
||||||
| Interaction models | 4, declared in the `.proto` | 4, chosen by return type | whatever you invent |
|
|
||||||
| Demand signalling | none in the API | `request(n)` on the wire | none at all |
|
|
||||||
| Deadlines | per call, absolute, propagating | none built in | none |
|
|
||||||
| Browser client | needs grpc-web + a proxy | yes, over the WebSocket transport | yes, natively |
|
|
||||||
| Boot 4 starters | `spring-boot-starter-grpc-server` / `-client` | `spring-boot-starter-rsocket` | `spring-boot-starter-websocket` |
|
|
||||||
|
|
||||||
## The three server sides, side by side
|
|
||||||
|
|
||||||
**gRPC** ([`GrpcQuoteService`](../src/main/java/com/ankurm/protocols/GrpcQuoteService.java)) is
|
|
||||||
generated-class inheritance. There is no registration code: the generated `ImplBase` is a
|
|
||||||
`BindableService` and Boot registers every such bean.
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Override
|
|
||||||
public void getQuote(QuoteRequest request, StreamObserver<Quote> observer) {
|
|
||||||
observer.onNext(toProto(source.at(request.getSymbol(), 0)));
|
|
||||||
observer.onCompleted();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**RSocket** ([`RSocketQuoteController`](../src/main/java/com/ankurm/protocols/RSocketQuoteController.java))
|
|
||||||
is annotation routing, and the interaction model *is the return type*:
|
|
||||||
|
|
||||||
```java
|
|
||||||
@MessageMapping("quote")
|
|
||||||
public Mono<Quote> quote(String symbol) { ... } // request/response
|
|
||||||
|
|
||||||
@MessageMapping("quotes")
|
|
||||||
public Flux<Quote> quotes(StreamSpec spec) { ... } // request/stream
|
|
||||||
```
|
|
||||||
|
|
||||||
**Raw WebSocket** ([`WebSocketQuoteHandler`](../src/main/java/com/ankurm/protocols/WebSocketQuoteHandler.java))
|
|
||||||
is a `switch` on a string, because there is nothing else:
|
|
||||||
|
|
||||||
```java
|
|
||||||
switch (parts[0]) {
|
|
||||||
case "QUOTE" -> send(session, source.at(parts[1], 0));
|
|
||||||
case "STREAM" -> { for (int i = 0; i < count; i++) { send(...); } }
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
That third block is the honest summary of what a raw WebSocket gives you: a bidirectional pipe
|
|
||||||
for text or bytes. Everything above it — routing, correlation, errors, versioning, demand
|
|
||||||
— is yours to invent, and inventing it is how teams end up with a private, undocumented
|
|
||||||
protocol that only its authors can debug. STOMP exists to stop that, and is covered in
|
|
||||||
[`../sse-websocket`](../../sse-websocket/README.md).
|
|
||||||
|
|
||||||
## Boot 4 version notes
|
|
||||||
|
|
||||||
Boot 4.1.1's BOM pins **grpc-java 1.83.1** and **protobuf-java 4.35.1**, both deliberately older
|
|
||||||
than the newest releases on Maven Central. Overriding them independently is how you get a
|
|
||||||
`NoSuchMethodError` between grpc-java and its shaded Netty. Note that these have moved since Boot
|
|
||||||
4.1.0, which pinned 1.80.0 and 4.34.2 — so a `spring.grpc.*` guide written against 4.1.0 is
|
|
||||||
already describing different jars.
|
|
||||||
|
|
||||||
RSocket is **rsocket-java 1.1.5**, and `spring-boot-starter-rsocket` brings
|
|
||||||
`jackson-dataformat-cbor` on purpose: CBOR, not JSON, is Spring's default RSocket data mime type.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Next: [2. How they measure](02-benchmarks.md)
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
# 2. How they measure
|
|
||||||
|
|
||||||
Previous: [1. Three protocols](01-three-protocols.md) · Next: [3. Payload](03-payload.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Read this before the numbers
|
|
||||||
|
|
||||||
Client and server are **the same process on one machine**, talking over loopback: two cores,
|
|
||||||
3.9 GB, JDK 25, Boot 4.1.1. That has three consequences, and ignoring them makes the numbers
|
|
||||||
say the opposite of the truth.
|
|
||||||
|
|
||||||
1. **There is no network.** On a real link, a 35-byte message and an 83-byte message differ by
|
|
||||||
more than the framing cost, and protobuf's size advantage starts paying. Here it does not.
|
|
||||||
2. **A protocol that does less looks faster.** WebSocket wins both benchmarks below partly because
|
|
||||||
it has no flow control, no deadlines, no schema and no per-call metadata. That is a real cost
|
|
||||||
difference, and it is also exactly what you give up.
|
|
||||||
3. **Ratios, not absolutes.** Absolute microsecond figures from a two-core sandbox mean nothing
|
|
||||||
for your hardware. The ordering and the rough factors are what transfer.
|
|
||||||
|
|
||||||
Re-measured on every `./scripts/run-all.sh`, and re-measured **in their own JVM invocation**.
|
|
||||||
That is not tidiness. The back-pressure tests in [chapter 4](04-backpressure.md) leave unbounded
|
|
||||||
producers spinning, and running the throughput benchmark after them on a two-core box halved
|
|
||||||
every number — gRPC fell from 44 801 msgs/s to 18 477. If you take one operational lesson from
|
|
||||||
this module rather than a protocol one, let it be that: a benchmark that shares a machine with
|
|
||||||
anything is measuring the machine.
|
|
||||||
|
|
||||||
## Request/response
|
|
||||||
|
|
||||||
5 000 sequential calls after 2 000 warm-up
|
|
||||||
([`request-response.txt`](output/request-response.txt)):
|
|
||||||
|
|
||||||
```
|
|
||||||
gRPC n=5000 p50= 291.3 us p99= 1015.3 us mean= 324.4 us ~3,083 calls/s
|
|
||||||
RSocket n=5000 p50= 300.9 us p99= 1337.4 us mean= 350.9 us ~2,850 calls/s
|
|
||||||
WebSocket n=5000 p50= 124.3 us p99= 1447.8 us mean= 172.8 us ~5,786 calls/s
|
|
||||||
```
|
|
||||||
|
|
||||||
**gRPC and RSocket are the same speed.** Within noise across runs the two swap places; treat them
|
|
||||||
as indistinguishable for request/response on a warm connection.
|
|
||||||
|
|
||||||
**Raw WebSocket is about twice as fast at the median — and has the worst tail.** p50 of
|
|
||||||
124 µs against a p99 of 1 448 µs is a 12x spread. gRPC's is 3.5x. The
|
|
||||||
median is cheap because a text frame over an already-open socket is nearly free; the tail is bad
|
|
||||||
because nothing is scheduling anything, so a GC pause or a scheduling hiccup lands on the request
|
|
||||||
undiluted.
|
|
||||||
|
|
||||||
If your SLO is a percentile rather than an average — and it should be — that table
|
|
||||||
does not say what a first glance suggests.
|
|
||||||
|
|
||||||
## Server streaming
|
|
||||||
|
|
||||||
50 000 messages on one connection
|
|
||||||
([`stream-throughput.txt`](output/stream-throughput.txt)):
|
|
||||||
|
|
||||||
```
|
|
||||||
gRPC 50,000 msgs in 1.116 s = 44,801 msgs/s ( 22.32 us each)
|
|
||||||
RSocket 50,000 msgs in 0.702 s = 71,175 msgs/s ( 14.05 us each)
|
|
||||||
WebSocket 50,000 msgs in 0.459 s = 109,022 msgs/s ( 9.17 us each)
|
|
||||||
```
|
|
||||||
|
|
||||||
Same ordering, wider gaps: WebSocket about 2.4x gRPC, RSocket about 1.6x.
|
|
||||||
|
|
||||||
Now read the WebSocket row again with its harness in view. The client's inbox is an
|
|
||||||
`ArrayBlockingQueue` sized to hold **all fifty thousand messages**, because there was nothing else
|
|
||||||
to do with them — a raw WebSocket consumer that falls behind can buffer or drop, and those
|
|
||||||
are the only two options. WebSocket "won" this benchmark by buffering the entire stream in the
|
|
||||||
client's heap.
|
|
||||||
|
|
||||||
That is not a rhetorical point. It is the next chapter.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Previous: [1. Three protocols](01-three-protocols.md) · Next: [3. Payload](03-payload.md)
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# 3. Payload: the one comparison that needs no benchmark
|
|
||||||
|
|
||||||
Previous: [2. Benchmarks](02-benchmarks.md) · Next: [4. Back-pressure](04-backpressure.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Five fields — a symbol, a sequence number, two doubles and a timestamp — encoded three
|
|
||||||
ways ([`payload-sizes.txt`](output/payload-sizes.txt)):
|
|
||||||
|
|
||||||
```
|
|
||||||
protobuf : 35 bytes 0a044141504c102a197b14ae47e11a5940215c8fc2f5281c5940288080abb4fef39203
|
|
||||||
JSON : 83 bytes {"symbol":"AAPL","seq":42,"bid":100.42,"ask":100.44,"epochMicros":1772000000000000}
|
|
||||||
CBOR : 67 bytes bf6673796d626f6c644141504c63736571182a63626964fb40591ae147ae147b6361736bfb40591c28f5c28f5c6b65706f63684d6963726f731b00064b9fe68ac000ff
|
|
||||||
JSON is 2.37x protobuf; CBOR is 1.91x protobuf
|
|
||||||
```
|
|
||||||
|
|
||||||
This is a property of the formats, not of a machine, so it is the one row of the comparison that
|
|
||||||
transfers to your hardware unchanged.
|
|
||||||
|
|
||||||
**Protobuf is 35 bytes because field names are integers.** `0a04 41 41 50 4c` is field 1, length
|
|
||||||
4, `AAPL`. There is no `"symbol"` on the wire at all — the name lives in the `.proto` file
|
|
||||||
that both sides compiled against. That is where the size comes from, and it is also the whole
|
|
||||||
argument about schemas: the saving and the coupling are the same fact.
|
|
||||||
|
|
||||||
**CBOR is 67 bytes and still writes the field names.** `6673796d626f6c` is a 6-character text
|
|
||||||
string, `symbol`. CBOR is binary JSON, not a schema format: it saves the punctuation and encodes
|
|
||||||
numbers compactly, and it keeps every key. So its advantage over JSON is real but modest, and it
|
|
||||||
does not require the coupling.
|
|
||||||
|
|
||||||
Concretely, on a stream of a million quotes: 35 MB, 67 MB, 83 MB. On a loopback
|
|
||||||
that difference disappears into memory bandwidth — which is exactly why the benchmarks in
|
|
||||||
[chapter 2](02-benchmarks.md) do not reward it and a real link would.
|
|
||||||
|
|
||||||
Two practical notes:
|
|
||||||
|
|
||||||
- **Spring's RSocket default is CBOR**, not JSON, which is why
|
|
||||||
`spring-boot-starter-rsocket` brings `jackson-dataformat-cbor`. If you are debugging with
|
|
||||||
`tcpdump` and expecting to read your payloads, that is why you cannot. The mechanism is codec
|
|
||||||
ordering, printed by
|
|
||||||
[`RSocketDefaultsTest`](../src/test/java/com/ankurm/protocols/RSocketDefaultsTest.java):
|
|
||||||
|
|
||||||
```
|
|
||||||
encoders : [CharSequenceEncoder, ByteBufferEncoder, ByteArrayEncoder, DataBufferEncoder, JacksonCborEncoder, JacksonJsonEncoder]
|
|
||||||
bare RSocketStrategies.create() : [CharSequenceEncoder, ByteBufferEncoder, ByteArrayEncoder, DataBufferEncoder]
|
|
||||||
```
|
|
||||||
|
|
||||||
CBOR sits *ahead of* JSON in the Boot-configured list, which is the whole of why it wins. Note
|
|
||||||
also that a bare `RSocketStrategies.create()` carries neither — an `RSocketRequester` built
|
|
||||||
without injecting Boot's strategies cannot encode your objects at all. And the class names
|
|
||||||
follow the Boot 4 rule: `JacksonCborEncoder`, not `Jackson2…`.
|
|
||||||
- **You can put protobuf on RSocket.** The encoding and the protocol are independent choices;
|
|
||||||
the row above is what each stack does *by default*, not what it is capable of.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Previous: [2. Benchmarks](02-benchmarks.md) · Next: [4. Back-pressure](04-backpressure.md)
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
# 4. Back-pressure: the measurement that decides it
|
|
||||||
|
|
||||||
Previous: [3. Payload](03-payload.md) · Next: [5. Choosing](05-choosing.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Every protocol comparison eventually reduces to one question, and it is not throughput. It is:
|
|
||||||
|
|
||||||
> **When the consumer stops keeping up, what happens?**
|
|
||||||
|
|
||||||
[`BackPressureTest`](../src/test/java/com/ankurm/protocols/BackPressureTest.java) asks it the
|
|
||||||
simplest way there is. Each server offers an unbounded stream. Each client takes a hundred
|
|
||||||
messages and then does nothing. How many did the server produce?
|
|
||||||
|
|
||||||
The sweep over quiet periods is what makes the answer conclusive. A number that stays flat as the
|
|
||||||
wait grows means something bounded the producer. A number that keeps climbing means nothing did.
|
|
||||||
|
|
||||||
[`backpressure.txt`](output/backpressure.txt):
|
|
||||||
|
|
||||||
```
|
|
||||||
protocol quiet 0.5s quiet 1s quiet 2s
|
|
||||||
RSocket 100 100 100
|
|
||||||
gRPC 103,425 449,318 1,208,219
|
|
||||||
WebSocket 19,664 143,870 320,255
|
|
||||||
```
|
|
||||||
|
|
||||||
RSocket produced **exactly one hundred**, at every quiet period. gRPC produced over a million in
|
|
||||||
two seconds. Raw WebSocket, three hundred thousand. These are re-measured on every run and the
|
|
||||||
absolute counts move by tens of percent; the three orders of magnitude between the rows do not.
|
|
||||||
|
|
||||||
(The growth is faster than linear because the loop is still being JIT-compiled during the shortest
|
|
||||||
run. The shape is the finding, not the exponent.)
|
|
||||||
|
|
||||||
## Why RSocket stops
|
|
||||||
|
|
||||||
`request(n)` is a **frame on the wire**. When the client's subscriber calls `subscription.request(100)`,
|
|
||||||
a REQUEST_N frame carries that number to the server, and Reactor's `Flux.generate` is called
|
|
||||||
exactly a hundred times and then not again. Nothing blocks; there is no buffer to size and no
|
|
||||||
thread parked. The producer simply is not invoked.
|
|
||||||
|
|
||||||
That is what "Reactive Streams over the network" means, and it is the entire reason RSocket
|
|
||||||
exists. The same property is why RSocket's streaming throughput in
|
|
||||||
[chapter 2](02-benchmarks.md) is respectable rather than the highest: it is doing real work per
|
|
||||||
batch that the others are not doing at all.
|
|
||||||
|
|
||||||
## Why the other two do not
|
|
||||||
|
|
||||||
**Raw WebSocket has no mechanism.** The frame protocol has no notion of demand. The server writes
|
|
||||||
until something underneath refuses — a socket buffer, Spring's `sendBufferSizeLimit`, the
|
|
||||||
heap. Every one of those 320 255 messages was serialised and written for a client that had
|
|
||||||
asked for a hundred.
|
|
||||||
|
|
||||||
**gRPC has HTTP/2 flow control, and it did not help.** This is the result I did not expect.
|
|
||||||
`StreamObserver.onNext` on a gRPC server **does not block**, so a plain server-streaming loop keeps
|
|
||||||
producing regardless of what the client is doing.
|
|
||||||
|
|
||||||
The documented answer is `ServerCallStreamObserver.isReady()`, and this module tests it rather than
|
|
||||||
citing it ([`grpc-isready.txt`](output/grpc-isready.txt)):
|
|
||||||
|
|
||||||
```
|
|
||||||
handler quiet 0.5s quiet 2s
|
|
||||||
plain onNext loop 258,145 1,421,778
|
|
||||||
isReady() checked before write 667,259 1,044,018
|
|
||||||
```
|
|
||||||
|
|
||||||
**It did not bound the producer.** The `isReady()` row is not lower than the plain one in any
|
|
||||||
useful sense — it produced 667 thousand messages in half a second for a client that
|
|
||||||
wanted a hundred — and across runs it has come out both far above and far below the plain
|
|
||||||
loop, which is its own kind of answer. I
|
|
||||||
have not established whether the surplus accumulates in the server's outbound queue, in the client
|
|
||||||
transport, or is discarded after the deframer, and I am not going to assert a mechanism I have not
|
|
||||||
read the source for.
|
|
||||||
|
|
||||||
What the numbers do establish is narrower and still worth knowing: **`isReady()` is not a demand
|
|
||||||
signal**, and on a fast link with a stalled consumer it does not behave like one. If you are
|
|
||||||
streaming from gRPC to a consumer that can fall behind, the bound has to come from your own
|
|
||||||
design — a paged API, an explicit "send me more" message on a bidirectional stream, or a
|
|
||||||
`count` in the request as `StreamQuotes` uses.
|
|
||||||
|
|
||||||
## The honest caveat
|
|
||||||
|
|
||||||
A loopback connection is the worst case for this test in the fairest possible way: the wire drains
|
|
||||||
instantly, so nothing downstream ever pushes back. On a slow real link, TCP and the HTTP/2 window
|
|
||||||
*would* eventually stop the gRPC and WebSocket producers — by blocking a thread or by
|
|
||||||
filling a buffer, but they would stop.
|
|
||||||
|
|
||||||
That does not rescue them, because the failure mode you actually meet is not "the network is
|
|
||||||
slow", it is "one consumer of forty is slow", and a shared server discovers that as latency for
|
|
||||||
everyone else or as a heap that will not shrink. The difference RSocket makes is that the slow
|
|
||||||
consumer's slowness is *expressed*, in a number, that the producer can act on before anything
|
|
||||||
fills up.
|
|
||||||
|
|
||||||
## The other thing gRPC does not do for you
|
|
||||||
|
|
||||||
Abandoning a gRPC stream produced this, repeatedly, in the server log:
|
|
||||||
|
|
||||||
```
|
|
||||||
java.lang.IllegalStateException: Failed to close the call
|
|
||||||
Caused by: java.lang.IllegalStateException: call already closed
|
|
||||||
```
|
|
||||||
|
|
||||||
That is the cancellation story from
|
|
||||||
[the Spring gRPC article](https://ankurm.com/spring-grpc-spring-boot-4/) showing up in practice:
|
|
||||||
gRPC does not interrupt your thread when a client leaves, it sets a flag on the `Context`, and a
|
|
||||||
handler that touches an already-closed stream throws. The loop in
|
|
||||||
[`GrpcQuoteService`](../src/main/java/com/ankurm/protocols/GrpcQuoteService.java) checks
|
|
||||||
`Context.current().isCancelled()` and returns without calling `onCompleted` for exactly this
|
|
||||||
reason.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Previous: [3. Payload](03-payload.md) · Next: [5. Choosing](05-choosing.md)
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
# 5. Choosing
|
|
||||||
|
|
||||||
Previous: [4. Back-pressure](04-backpressure.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The decision table
|
|
||||||
|
|
||||||
| If | Pick | Because |
|
|
||||||
|---|---|---|
|
|
||||||
| Service-to-service RPC, polyglot team, you want a schema | **gRPC** | The `.proto` is a contract other languages generate from. Deadlines are absolute and propagate. Boot 4 makes it a first-class starter. |
|
|
||||||
| A stream whose consumer can fall behind | **RSocket** | `request(n)` is the only demand signal that crosses the wire. 100 vs 1.3 million is not a tuning difference. |
|
|
||||||
| A browser is one of the peers | **WebSocket**, with STOMP on it | gRPC needs grpc-web and a proxy. RSocket over WebSocket works but the client ecosystem is thin. |
|
|
||||||
| Server pushes, client never speaks | **none of these** — use SSE | See [`../sse-websocket`](../../sse-websocket/README.md). |
|
|
||||||
| You want the lowest possible median latency and control both ends | **raw WebSocket** | It is fastest here because it does the least. Read the p99 before deciding that is what you want. |
|
|
||||||
| Long-lived bidirectional exchange with independent streams | **RSocket channel** or **gRPC bidi** | Both give you two independent streams; only RSocket gives each of them demand. |
|
|
||||||
|
|
||||||
## What each one really costs
|
|
||||||
|
|
||||||
**gRPC** costs a build step, a schema you must version, and an operational story for HTTP/2 through
|
|
||||||
your proxies. In exchange you get the best-supported cross-language RPC there is, and deadlines
|
|
||||||
— which is a bigger deal than it sounds, because an absolute deadline that propagates across
|
|
||||||
hops is the single most effective defence against one slow dependency taking down a system.
|
|
||||||
|
|
||||||
**RSocket** costs ecosystem. It is the least deployed of the three, the client libraries outside
|
|
||||||
Java and JavaScript are thin, and if you are not already reactive, `Mono` and `Flux` in your
|
|
||||||
service signatures is a real commitment. In exchange you get the only demand signalling in the
|
|
||||||
list, plus resumption and leasing.
|
|
||||||
|
|
||||||
**Raw WebSocket** costs everything you then invent: routing, correlation, error shape, versioning,
|
|
||||||
and the demand mechanism you will eventually need. That is the cost STOMP exists to prevent, and
|
|
||||||
if you find yourself designing a text protocol with a command word at the front — as
|
|
||||||
[`WebSocketQuoteHandler`](../src/main/java/com/ankurm/protocols/WebSocketQuoteHandler.java)
|
|
||||||
deliberately does — that is the signal to stop and use one.
|
|
||||||
|
|
||||||
## Should you use any of them?
|
|
||||||
|
|
||||||
Three cases where the answer is no:
|
|
||||||
|
|
||||||
- **Request/response between two services that both speak HTTP already.** A REST endpoint with a
|
|
||||||
`RestClient` and a timeout is fine, and everything in your organisation already knows how to
|
|
||||||
observe, cache, proxy and debug it. Reach for gRPC when the schema or the deadline propagation
|
|
||||||
is the thing you need, not because it benchmarks faster.
|
|
||||||
- **Streaming that is really batching.** If the consumer processes a page at a time anyway,
|
|
||||||
paginated HTTP has back-pressure for free: the consumer asks for the next page when it is ready.
|
|
||||||
That is `request(n)` with a URL.
|
|
||||||
- **Events that outlive the connection.** None of these three persist anything. A dropped
|
|
||||||
connection loses the messages in flight. If that matters, the answer is a broker —
|
|
||||||
[`../kafka-basics`](../../kafka-basics/README.md),
|
|
||||||
[`../rabbitmq`](../../rabbitmq/README.md), and the comparison in
|
|
||||||
[`../broker-comparison`](../../broker-comparison/README.md).
|
|
||||||
|
|
||||||
## And the benchmark caveat, one more time
|
|
||||||
|
|
||||||
Every number in this module was measured with client and server in one JVM on one machine. That
|
|
||||||
deletes the network, which is where protobuf's 35 bytes would pay and where the tail latencies
|
|
||||||
would look completely different. The measurement that does transfer is
|
|
||||||
[chapter 4](04-backpressure.md), because "how many did the server produce for a consumer that
|
|
||||||
wanted a hundred" is a property of the protocol and not of the link.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Previous: [4. Back-pressure](04-backpressure.md)
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
=== unbounded stream; client takes 100, then goes quiet ===
|
|
||||||
protocol quiet 0.5s quiet 1s quiet 2s
|
|
||||||
RSocket 100 100 100
|
|
||||||
gRPC 103,425 449,318 1,208,219
|
|
||||||
WebSocket 19,664 143,870 320,255
|
|
||||||
RSocket is flat: the server produced exactly what request(n) asked for.
|
|
||||||
gRPC grows 11.7x between 0.5 s and 2 s; WebSocket grows 16.3x.
|
|
||||||
Growth that tracks the wait, rather than settling at a buffer size,
|
|
||||||
means nothing bounded the producer. (Growth is faster than linear
|
|
||||||
because the loop is still being JIT-compiled during the first run.)
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
=== gRPC server streaming, client takes 100 then stops ===
|
|
||||||
handler quiet 0.5s quiet 2s
|
|
||||||
plain onNext loop 258,145 1,421,778
|
|
||||||
isReady() checked before write 667,259 1,044,018
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
=== one Quote, five fields, three encodings ===
|
|
||||||
protobuf : 35 bytes 0a044141504c102a197b14ae47e11a5940215c8fc2f5281c5940288080abb4fef39203
|
|
||||||
JSON : 83 bytes {"symbol":"AAPL","seq":42,"bid":100.42,"ask":100.44,"epochMicros":1772000000000000}
|
|
||||||
CBOR : 67 bytes bf6673796d626f6c644141504c63736571182a63626964fb40591ae147ae147b6361736bfb40591c28f5c28f5c6b65706f63684d6963726f731b00064b9fe68ac000ff
|
|
||||||
JSON is 2.37x protobuf; CBOR is 1.91x protobuf
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
=== request/response, 5000 sequential calls after 2000 warm-up, loopback, JDK 25.0.4.1 ===
|
|
||||||
gRPC n=5000 p50= 302.0 us p99= 994.7 us mean= 335.1 us ~2,984 calls/s
|
|
||||||
RSocket n=5000 p50= 265.9 us p99= 1166.2 us mean= 311.9 us ~3,206 calls/s
|
|
||||||
WebSocket n=5000 p50= 122.8 us p99= 1893.9 us mean= 184.8 us ~5,410 calls/s
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
=== Boot-configured RSocketStrategies ===
|
|
||||||
encoders : [CharSequenceEncoder, ByteBufferEncoder, ByteArrayEncoder, DataBufferEncoder, JacksonCborEncoder, JacksonJsonEncoder]
|
|
||||||
decoders : [StringDecoder, ByteBufferDecoder, ByteArrayDecoder, DataBufferDecoder, JacksonCborDecoder, JacksonJsonDecoder]
|
|
||||||
bare RSocketStrategies.create() encoders : [CharSequenceEncoder, ByteBufferEncoder, ByteArrayEncoder, DataBufferEncoder]
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
=== server streaming, 50000 messages on one connection, loopback ===
|
|
||||||
gRPC 50,000 msgs in 1.341 s = 37,285 msgs/s ( 26.82 us each)
|
|
||||||
RSocket 50,000 msgs in 0.900 s = 55,580 msgs/s ( 17.99 us each)
|
|
||||||
WebSocket 50,000 msgs in 0.839 s = 59,630 msgs/s ( 16.77 us each)
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.321 s -- in com.ankurm.protocols.PayloadSizeTest
|
|
||||||
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 11.76 s -- in com.ankurm.protocols.RequestResponseBenchmarkTest
|
|
||||||
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 11.29 s -- in com.ankurm.protocols.BackPressureTest
|
|
||||||
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 7.409 s -- in com.ankurm.protocols.GrpcIsReadyTest
|
|
||||||
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 6.474 s -- in com.ankurm.protocols.RSocketDefaultsTest
|
|
||||||
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 7.244 s -- in com.ankurm.protocols.StreamThroughputBenchmarkTest
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
|
||||||
<modelVersion>4.0.0</modelVersion>
|
|
||||||
|
|
||||||
<parent>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-parent</artifactId>
|
|
||||||
<version>4.1.1</version>
|
|
||||||
<relativePath/>
|
|
||||||
</parent>
|
|
||||||
|
|
||||||
<groupId>com.ankurm</groupId>
|
|
||||||
<artifactId>protocol-comparison</artifactId>
|
|
||||||
<version>1.0</version>
|
|
||||||
<packaging>jar</packaging>
|
|
||||||
|
|
||||||
<properties>
|
|
||||||
<java.version>25</java.version>
|
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
|
||||||
</properties>
|
|
||||||
|
|
||||||
<dependencies>
|
|
||||||
<!-- One application speaks all three protocols, so the comparison is not confounded by
|
|
||||||
different JVMs, different heaps or different warm-up states. -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-webmvc</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<!-- RSocket. The starter drags in reactor-netty and jackson-dataformat-cbor; CBOR is not
|
|
||||||
an accident, it is RSocket's default data mime type in Spring. -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-rsocket</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<!-- gRPC. Two starters, both Boot-managed. See ankurm.com/spring-grpc-spring-boot-4/ -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-grpc-server</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-grpc-client</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-jackson</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>io.projectreactor</groupId>
|
|
||||||
<artifactId>reactor-test</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
|
|
||||||
<build>
|
|
||||||
<plugins>
|
|
||||||
<!-- protoc and the gRPC codegen plugin resolve as Maven artifacts for this OS and
|
|
||||||
architecture. Nothing to install locally. Both versions come from the Boot BOM;
|
|
||||||
overriding them independently is how you get a NoSuchMethodError between grpc-java
|
|
||||||
and its shaded Netty. -->
|
|
||||||
<plugin>
|
|
||||||
<groupId>io.github.ascopes</groupId>
|
|
||||||
<artifactId>protobuf-maven-plugin</artifactId>
|
|
||||||
<version>3.9.0</version>
|
|
||||||
<configuration>
|
|
||||||
<protocVersion>${protobuf-java.version}</protocVersion>
|
|
||||||
<binaryMavenPlugins>
|
|
||||||
<binaryMavenPlugin>
|
|
||||||
<groupId>io.grpc</groupId>
|
|
||||||
<artifactId>protoc-gen-grpc-java</artifactId>
|
|
||||||
<version>${grpc-java.version}</version>
|
|
||||||
</binaryMavenPlugin>
|
|
||||||
</binaryMavenPlugins>
|
|
||||||
</configuration>
|
|
||||||
<executions>
|
|
||||||
<execution><goals><goal>generate</goal></goals></execution>
|
|
||||||
</executions>
|
|
||||||
</plugin>
|
|
||||||
<plugin>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
|
||||||
</plugin>
|
|
||||||
</plugins>
|
|
||||||
</build>
|
|
||||||
</project>
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Regenerate every file under docs/output/. Needs a JDK and nothing else: protoc and the gRPC
|
|
||||||
# codegen plugin resolve as Maven artifacts, and all three servers start inside the test JVM.
|
|
||||||
#
|
|
||||||
# The benchmark numbers are NOT deterministic. They are re-measured on every run and the ratios
|
|
||||||
# between the three protocols are what the documentation quotes.
|
|
||||||
set -eu
|
|
||||||
cd "$(dirname "$0")/.."
|
|
||||||
LOG=/tmp/protocol-comparison-test.log
|
|
||||||
|
|
||||||
# Everything, for the test count and the non-timing transcripts.
|
|
||||||
MAVEN_OPTS=${MAVEN_OPTS:--Xmx900m} mvn -B test > "$LOG" 2>&1
|
|
||||||
|
|
||||||
# The two timing benchmarks are then re-run ALONE. This is not tidiness: the back-pressure and
|
|
||||||
# isReady tests leave unbounded producers spinning, and on a two-core box that halves every
|
|
||||||
# throughput number measured after them. Benchmarks share a JVM with nothing.
|
|
||||||
BENCH=/tmp/protocol-comparison-bench.log
|
|
||||||
MAVEN_OPTS=${MAVEN_OPTS:--Xmx900m} mvn -B test \
|
|
||||||
-Dtest=RequestResponseBenchmarkTest,StreamThroughputBenchmarkTest > "$BENCH" 2>&1
|
|
||||||
|
|
||||||
# Interleaved log lines from other contexts have to come out, or the transcripts are unreadable.
|
|
||||||
clean() { grep -vE '^2026-|^\s*$|^WARNING|^[[:space:]]+at |^Caused by|^java\.|^org\.|Initializing|Completed initialization'; }
|
|
||||||
|
|
||||||
sed -n '/=== one Quote, five fields/,/^JSON is /p' "$LOG" | clean > docs/output/payload-sizes.txt
|
|
||||||
sed -n '/=== request\/response/,/^WebSocket /p' "$BENCH" | clean > docs/output/request-response.txt
|
|
||||||
sed -n '/=== server streaming, 50000/,/^WebSocket /p' "$BENCH" | clean > docs/output/stream-throughput.txt
|
|
||||||
{ grep -m1 '=== unbounded stream' "$LOG"; grep -m1 '^protocol ' "$LOG"
|
|
||||||
grep -m1 '^RSocket *[0-9]' "$LOG"; grep -m1 '^gRPC *[0-9]' "$LOG"
|
|
||||||
grep -m1 '^WebSocket *[0-9]' "$LOG"
|
|
||||||
sed -n '/^RSocket is flat/,/JIT-compiled/p' "$LOG"; } > docs/output/backpressure.txt
|
|
||||||
{ grep -m1 '=== gRPC server streaming' "$LOG"; grep -m1 '^handler ' "$LOG"
|
|
||||||
grep -m1 '^plain onNext loop' "$LOG"; grep -m1 '^isReady() checked' "$LOG"; } \
|
|
||||||
> docs/output/grpc-isready.txt
|
|
||||||
|
|
||||||
sed -n '/=== Boot-configured RSocketStrategies/,/^bare RSocketStrategies/p' "$LOG" | clean \
|
|
||||||
> docs/output/rsocket-codecs.txt
|
|
||||||
|
|
||||||
grep -E 'Tests run:.*in com\.ankurm' "$LOG" | sed 's/^\[INFO\] //' > docs/output/tests.txt
|
|
||||||
|
|
||||||
echo "regenerated:"; ls -1 docs/output/
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
package com.ankurm.protocols;
|
|
||||||
|
|
||||||
import com.ankurm.protocols.grpc.Quote;
|
|
||||||
import com.ankurm.protocols.grpc.QuoteRequest;
|
|
||||||
import com.ankurm.protocols.grpc.QuoteServiceGrpc;
|
|
||||||
import com.ankurm.protocols.grpc.StreamRequest;
|
|
||||||
import io.grpc.Context;
|
|
||||||
import io.grpc.stub.ServerCallStreamObserver;
|
|
||||||
import io.grpc.stub.StreamObserver;
|
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
|
||||||
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The gRPC side. No registration code: the generated {@code ImplBase} is a
|
|
||||||
* {@code BindableService} and Boot registers every such bean with the server.
|
|
||||||
*
|
|
||||||
* <p>The cancellation check in {@link #streamQuotes} is not decoration. gRPC does not interrupt
|
|
||||||
* your thread when a client goes away — it sets a flag on the {@link Context} — so a
|
|
||||||
* loop that never looks keeps producing into a stream nobody is reading. That is covered at
|
|
||||||
* length in <a href="https://ankurm.com/spring-grpc-spring-boot-4/">the Spring gRPC article</a>;
|
|
||||||
* here it matters because the back-pressure benchmark deliberately abandons a stream.
|
|
||||||
*
|
|
||||||
* @see <a href="../../../../../docs/04-backpressure.md">docs/04-backpressure.md</a>
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
public class GrpcQuoteService extends QuoteServiceGrpc.QuoteServiceImplBase {
|
|
||||||
|
|
||||||
/** Counts what the SERVER produced, comparable with the RSocket and WebSocket counters. */
|
|
||||||
static final AtomicLong PRODUCED = new AtomicLong();
|
|
||||||
|
|
||||||
private final QuoteSource source;
|
|
||||||
|
|
||||||
GrpcQuoteService(QuoteSource source) {
|
|
||||||
this.source = source;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void getQuote(QuoteRequest request, StreamObserver<Quote> observer) {
|
|
||||||
observer.onNext(toProto(source.at(request.getSymbol(), 0)));
|
|
||||||
observer.onCompleted();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An effectively unbounded stream, for the back-pressure comparison. A consumer that stops
|
|
||||||
* calling {@code next()} closes the HTTP/2 flow-control window, and this loop blocks inside
|
|
||||||
* {@code onNext} — so the server stops producing, but it stops by blocking a thread
|
|
||||||
* rather than by being asked to stop.
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public void streamUnbounded(QuoteRequest request, StreamObserver<Quote> observer) {
|
|
||||||
PRODUCED.set(0);
|
|
||||||
while (!Context.current().isCancelled()) {
|
|
||||||
observer.onNext(toProto(source.at(request.getSymbol(), PRODUCED.get())));
|
|
||||||
PRODUCED.incrementAndGet();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The fix, and the reason the unfixed version is worth measuring.
|
|
||||||
*
|
|
||||||
* <p>{@code StreamObserver.onNext} on a gRPC server <strong>never blocks</strong>. If the
|
|
||||||
* client is not reading, the message is queued in the server's outbound buffer and the loop
|
|
||||||
* keeps going — HTTP/2 flow control governs the wire, not your code. The only thing
|
|
||||||
* that connects the two is {@link ServerCallStreamObserver#isReady()}, and using it means
|
|
||||||
* restructuring the handler around {@code setOnReadyHandler} rather than writing a loop.
|
|
||||||
*
|
|
||||||
* <p>The spin here is deliberately the simplest possible demonstration rather than the
|
|
||||||
* shape you would ship; a real implementation registers an on-ready handler and returns.
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public void streamUnboundedReady(QuoteRequest request, StreamObserver<Quote> observer) {
|
|
||||||
PRODUCED.set(0);
|
|
||||||
ServerCallStreamObserver<Quote> ready = (ServerCallStreamObserver<Quote>) observer;
|
|
||||||
while (!ready.isCancelled()) {
|
|
||||||
if (!ready.isReady()) {
|
|
||||||
Thread.onSpinWait();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
ready.onNext(toProto(source.at(request.getSymbol(), PRODUCED.get())));
|
|
||||||
PRODUCED.incrementAndGet();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void produced(QuoteRequest request, StreamObserver<com.ankurm.protocols.grpc.ProducedCount> observer) {
|
|
||||||
observer.onNext(com.ankurm.protocols.grpc.ProducedCount.newBuilder()
|
|
||||||
.setCount(PRODUCED.get()).build());
|
|
||||||
observer.onCompleted();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void streamQuotes(StreamRequest request, StreamObserver<Quote> observer) {
|
|
||||||
for (int i = 0; i < request.getCount(); i++) {
|
|
||||||
if (Context.current().isCancelled()) {
|
|
||||||
// Do NOT call onCompleted/onError here: the stream is already closed and
|
|
||||||
// touching it throws IllegalStateException. Just return.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
observer.onNext(toProto(source.at(request.getSymbol(), i)));
|
|
||||||
if (request.getDelayMs() > 0) {
|
|
||||||
try {
|
|
||||||
Thread.sleep(request.getDelayMs());
|
|
||||||
}
|
|
||||||
catch (InterruptedException ex) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
observer.onCompleted();
|
|
||||||
}
|
|
||||||
|
|
||||||
static Quote toProto(com.ankurm.protocols.Quote q) {
|
|
||||||
return Quote.newBuilder()
|
|
||||||
.setSymbol(q.symbol()).setSeq(q.seq())
|
|
||||||
.setBid(q.bid()).setAsk(q.ask())
|
|
||||||
.setEpochMicros(q.epochMicros())
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
package com.ankurm.protocols;
|
|
||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One application, three protocols, one set of data.
|
|
||||||
*
|
|
||||||
* <p>gRPC on 9090, RSocket on 7000, WebSocket on the servlet port. Serving the same two
|
|
||||||
* operations from the same JVM is the only way to make a benchmark mean anything: the heap, the
|
|
||||||
* JIT state, the CPU and the data generator are shared, so a difference in the numbers is a
|
|
||||||
* difference in the transports.
|
|
||||||
*
|
|
||||||
* @see <a href="../../../../../docs/01-three-protocols.md">docs/01-three-protocols.md</a>
|
|
||||||
*/
|
|
||||||
@SpringBootApplication
|
|
||||||
public class ProtocolComparisonApplication {
|
|
||||||
|
|
||||||
public static void main(String[] args) {
|
|
||||||
SpringApplication.run(ProtocolComparisonApplication.class, args);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package com.ankurm.protocols;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The JSON/CBOR shape, field-for-field identical to the protobuf {@code Quote} message, so the
|
|
||||||
* encoded sizes in {@code docs/output/payload-sizes.txt} compare like with like.
|
|
||||||
*/
|
|
||||||
public record Quote(String symbol, long seq, double bid, double ask, long epochMicros) {
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
package com.ankurm.protocols;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deterministic quote generation, shared by all three protocol adapters.
|
|
||||||
*
|
|
||||||
* <p>Deliberately cheap and deliberately allocation-free apart from the record: a benchmark of
|
|
||||||
* three transports must not spend its time in the thing behind them.
|
|
||||||
*/
|
|
||||||
@Component
|
|
||||||
public class QuoteSource {
|
|
||||||
|
|
||||||
public Quote at(String symbol, long seq) {
|
|
||||||
double base = 100.0 + (seq % 97) * 0.01;
|
|
||||||
Instant now = Instant.now();
|
|
||||||
return new Quote(symbol, seq, base, base + 0.02,
|
|
||||||
now.getEpochSecond() * 1_000_000L + now.getNano() / 1_000L);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
package com.ankurm.protocols;
|
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.messaging.handler.annotation.MessageMapping;
|
|
||||||
import org.springframework.stereotype.Controller;
|
|
||||||
import reactor.core.publisher.Flux;
|
|
||||||
import reactor.core.publisher.Mono;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The RSocket side. The same two operations, and one extra that exists only to make
|
|
||||||
* back-pressure visible.
|
|
||||||
*
|
|
||||||
* <p>Note what the signatures say that the other two protocols' cannot: {@code Mono} means
|
|
||||||
* request/response, {@code Flux} means request/stream, and the choice of interaction model is
|
|
||||||
* the return type rather than a separate declaration. RSocket has four (fire-and-forget,
|
|
||||||
* request/response, request/stream, channel) and Spring picks from the method shape.
|
|
||||||
*
|
|
||||||
* @see <a href="../../../../../docs/04-backpressure.md">docs/04-backpressure.md</a>
|
|
||||||
*/
|
|
||||||
@Controller
|
|
||||||
public class RSocketQuoteController {
|
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(RSocketQuoteController.class);
|
|
||||||
|
|
||||||
/** Counts what the SERVER produced, which is the number the back-pressure test cares about. */
|
|
||||||
static final AtomicLong PRODUCED = new AtomicLong();
|
|
||||||
|
|
||||||
private final QuoteSource source;
|
|
||||||
|
|
||||||
RSocketQuoteController(QuoteSource source) {
|
|
||||||
this.source = source;
|
|
||||||
}
|
|
||||||
|
|
||||||
@MessageMapping("quote")
|
|
||||||
public Mono<Quote> quote(String symbol) {
|
|
||||||
return Mono.just(source.at(symbol, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
@MessageMapping("quotes")
|
|
||||||
public Flux<Quote> quotes(StreamSpec spec) {
|
|
||||||
Flux<Quote> flux = Flux.range(0, spec.count()).map(i -> source.at(spec.symbol(), i));
|
|
||||||
return spec.delayMs() > 0 ? flux.delayElements(Duration.ofMillis(spec.delayMs())) : flux;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An unbounded generator. It emits only when the transport asks, so the count it reaches is
|
|
||||||
* a direct read-out of how much the consumer requested — which is the whole argument
|
|
||||||
* for RSocket.
|
|
||||||
*/
|
|
||||||
@MessageMapping("quotes.unbounded")
|
|
||||||
public Flux<Quote> unbounded(String symbol) {
|
|
||||||
PRODUCED.set(0);
|
|
||||||
return Flux.generate(() -> 0L, (seq, sink) -> {
|
|
||||||
sink.next(source.at(symbol, seq));
|
|
||||||
PRODUCED.incrementAndGet();
|
|
||||||
return seq + 1;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@MessageMapping("produced")
|
|
||||||
public Mono<Long> produced() {
|
|
||||||
return Mono.just(PRODUCED.get());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Request payload for {@code quotes}. */
|
|
||||||
public record StreamSpec(String symbol, int count, int delayMs) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package com.ankurm.protocols;
|
|
||||||
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
|
||||||
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
|
||||||
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* {@code @EnableWebSocket}, not {@code @EnableWebSocketMessageBroker}: this module wants the raw
|
|
||||||
* transport, with no broker in the path, so the benchmark measures a socket rather than a routing
|
|
||||||
* layer. The {@code sse-websocket} module in this repository is the STOMP version.
|
|
||||||
*/
|
|
||||||
@Configuration
|
|
||||||
@EnableWebSocket
|
|
||||||
public class WebSocketConfig implements WebSocketConfigurer {
|
|
||||||
|
|
||||||
private final WebSocketQuoteHandler handler;
|
|
||||||
|
|
||||||
WebSocketConfig(WebSocketQuoteHandler handler) {
|
|
||||||
this.handler = handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
|
||||||
registry.addHandler(handler, "/quotes").setAllowedOriginPatterns("*");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
package com.ankurm.protocols;
|
|
||||||
|
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
|
||||||
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import org.springframework.web.socket.CloseStatus;
|
|
||||||
import org.springframework.web.socket.TextMessage;
|
|
||||||
import org.springframework.web.socket.WebSocketSession;
|
|
||||||
import org.springframework.web.socket.handler.TextWebSocketHandler;
|
|
||||||
import tools.jackson.databind.ObjectMapper;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The WebSocket side, with no STOMP and no framework on top: a two-word text protocol, so the
|
|
||||||
* benchmark measures the transport and not a broker.
|
|
||||||
*
|
|
||||||
* <pre>
|
|
||||||
* QUOTE <symbol> -> one JSON quote
|
|
||||||
* STREAM <symbol> <count> <delayMs> -> count JSON quotes, then "END"
|
|
||||||
* UNBOUNDED <symbol> -> quotes until the session dies
|
|
||||||
* </pre>
|
|
||||||
*
|
|
||||||
* <p>The absence worth noticing is in {@code UNBOUNDED}: there is nowhere for a consumer to say
|
|
||||||
* how many it wants. {@code sendMessage} either succeeds, blocks, or eventually throws when
|
|
||||||
* Spring's send buffer limit is exceeded. That is not a gap in this handler — it is what a
|
|
||||||
* raw WebSocket offers.
|
|
||||||
*
|
|
||||||
* @see <a href="../../../../../docs/04-backpressure.md">docs/04-backpressure.md</a>
|
|
||||||
*/
|
|
||||||
@Component
|
|
||||||
public class WebSocketQuoteHandler extends TextWebSocketHandler {
|
|
||||||
|
|
||||||
/** Counts what the SERVER wrote, comparable with RSocket's PRODUCED. */
|
|
||||||
static final AtomicLong PRODUCED = new AtomicLong();
|
|
||||||
|
|
||||||
private final QuoteSource source;
|
|
||||||
private final ObjectMapper mapper;
|
|
||||||
|
|
||||||
WebSocketQuoteHandler(QuoteSource source, ObjectMapper mapper) {
|
|
||||||
this.source = source;
|
|
||||||
this.mapper = mapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
|
|
||||||
String[] parts = message.getPayload().trim().split("\\s+");
|
|
||||||
switch (parts[0]) {
|
|
||||||
case "QUOTE" -> send(session, source.at(parts[1], 0));
|
|
||||||
case "STREAM" -> {
|
|
||||||
int count = Integer.parseInt(parts[2]);
|
|
||||||
int delay = parts.length > 3 ? Integer.parseInt(parts[3]) : 0;
|
|
||||||
for (int i = 0; i < count; i++) {
|
|
||||||
send(session, source.at(parts[1], i));
|
|
||||||
if (delay > 0) {
|
|
||||||
Thread.sleep(delay);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
session.sendMessage(new TextMessage("END"));
|
|
||||||
}
|
|
||||||
case "UNBOUNDED" -> {
|
|
||||||
PRODUCED.set(0);
|
|
||||||
// No requestN. Nothing here can be told to slow down: the loop writes until the
|
|
||||||
// socket, the send buffer or the send-time limit stops it. Catching the write
|
|
||||||
// failure keeps an abandoned stream from filling the log with one stack trace
|
|
||||||
// per run -- and note that catching it is the ONLY notification that arrives.
|
|
||||||
try {
|
|
||||||
while (session.isOpen()) {
|
|
||||||
send(session, source.at(parts[1], PRODUCED.get()));
|
|
||||||
PRODUCED.incrementAndGet();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex) {
|
|
||||||
// Broken pipe. The client left; there was no other way to find out.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "PRODUCED" -> session.sendMessage(new TextMessage(Long.toString(PRODUCED.get())));
|
|
||||||
default -> session.sendMessage(new TextMessage("ERR unknown command"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void handleTransportError(WebSocketSession session, Throwable exception) {
|
|
||||||
// Where an over-buffered slow consumer surfaces: as a transport error on the writer,
|
|
||||||
// long after the consumer stopped reading.
|
|
||||||
PRODUCED.addAndGet(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
|
|
||||||
}
|
|
||||||
|
|
||||||
private void send(WebSocketSession session, Quote quote) throws Exception {
|
|
||||||
session.sendMessage(new TextMessage(mapper.writeValueAsString(quote)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
syntax = "proto3";
|
|
||||||
|
|
||||||
package quotes;
|
|
||||||
|
|
||||||
option java_multiple_files = true;
|
|
||||||
option java_package = "com.ankurm.protocols.grpc";
|
|
||||||
|
|
||||||
// The same two operations the RSocket and WebSocket sides expose, so the comparison is between
|
|
||||||
// transports rather than between designs.
|
|
||||||
service QuoteService {
|
|
||||||
// Request/response.
|
|
||||||
rpc GetQuote (QuoteRequest) returns (Quote);
|
|
||||||
// Server streaming: count quotes, as fast as the transport allows.
|
|
||||||
rpc StreamQuotes (StreamRequest) returns (stream Quote);
|
|
||||||
// Never completes on its own. Used to show what stops a gRPC server producing.
|
|
||||||
rpc StreamUnbounded (QuoteRequest) returns (stream Quote);
|
|
||||||
// The same unbounded stream, but the server checks ServerCallStreamObserver.isReady()
|
|
||||||
// before every write. Same API, different memory profile.
|
|
||||||
rpc StreamUnboundedReady (QuoteRequest) returns (stream Quote);
|
|
||||||
// How many messages the server has produced on the current unbounded stream.
|
|
||||||
rpc Produced (QuoteRequest) returns (ProducedCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
message ProducedCount {
|
|
||||||
int64 count = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
message QuoteRequest {
|
|
||||||
string symbol = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
message StreamRequest {
|
|
||||||
string symbol = 1;
|
|
||||||
int32 count = 2;
|
|
||||||
// Milliseconds to sleep between emissions; 0 means "as fast as possible".
|
|
||||||
int32 delay_ms = 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
message Quote {
|
|
||||||
string symbol = 1;
|
|
||||||
int64 seq = 2;
|
|
||||||
double bid = 3;
|
|
||||||
double ask = 4;
|
|
||||||
int64 epoch_micros = 5;
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
spring:
|
|
||||||
application:
|
|
||||||
name: protocol-comparison
|
|
||||||
rsocket:
|
|
||||||
server:
|
|
||||||
# RSocket gets its own port and its own Reactor Netty server. TCP here; "websocket" is the
|
|
||||||
# other transport and is what you use when the client is a browser.
|
|
||||||
port: 7000
|
|
||||||
transport: tcp
|
|
||||||
grpc:
|
|
||||||
server:
|
|
||||||
# spring.grpc.server.port -- note the spring. prefix and that "port: -1" means in-process
|
|
||||||
# only. See the property table in ankurm.com/spring-grpc-spring-boot-4/
|
|
||||||
port: 9090
|
|
||||||
|
|
||||||
server:
|
|
||||||
port: 8080
|
|
||||||
|
|
||||||
logging:
|
|
||||||
level:
|
|
||||||
io.grpc: WARN
|
|
||||||
io.rsocket: WARN
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
package com.ankurm.protocols;
|
|
||||||
|
|
||||||
import java.util.Iterator;
|
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
|
||||||
|
|
||||||
import com.ankurm.protocols.grpc.QuoteRequest;
|
|
||||||
import com.ankurm.protocols.grpc.QuoteServiceGrpc;
|
|
||||||
import io.grpc.ManagedChannel;
|
|
||||||
import io.grpc.ManagedChannelBuilder;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.reactivestreams.Subscription;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
|
||||||
import org.springframework.messaging.rsocket.RSocketRequester;
|
|
||||||
import org.springframework.messaging.rsocket.RSocketStrategies;
|
|
||||||
import reactor.core.publisher.BaseSubscriber;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The measurement that decides the article.
|
|
||||||
*
|
|
||||||
* <p>Each protocol serves an unbounded stream. Each client takes a hundred messages and then does
|
|
||||||
* nothing for a while. The question is: <strong>how many did the server produce?</strong>
|
|
||||||
*
|
|
||||||
* <p>The sweep over quiet periods is what makes the answer conclusive. A number that stays flat
|
|
||||||
* as the quiet period grows means something bounded the producer. A number that grows in
|
|
||||||
* proportion to the wait means <em>nothing did</em> — the producer is running flat out and
|
|
||||||
* the only limit is the clock.
|
|
||||||
*/
|
|
||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
|
||||||
properties = {"spring.rsocket.server.port=7003", "spring.grpc.server.port=9093"})
|
|
||||||
class BackPressureTest {
|
|
||||||
|
|
||||||
private static final int WANTED = 100;
|
|
||||||
private static final long[] QUIET_MS = {500, 1_000, 2_000};
|
|
||||||
|
|
||||||
@LocalServerPort
|
|
||||||
int httpPort;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
RSocketStrategies strategies;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void howManyDidTheServerProduce() throws Exception {
|
|
||||||
System.out.println("=== unbounded stream; client takes " + WANTED + ", then goes quiet ===");
|
|
||||||
System.out.printf("%-14s %10s %10s %10s%n", "protocol", "quiet 0.5s", "quiet 1s", "quiet 2s");
|
|
||||||
|
|
||||||
long[] rs = new long[QUIET_MS.length];
|
|
||||||
long[] gr = new long[QUIET_MS.length];
|
|
||||||
long[] ws = new long[QUIET_MS.length];
|
|
||||||
for (int i = 0; i < QUIET_MS.length; i++) {
|
|
||||||
rs[i] = rsocket(QUIET_MS[i]);
|
|
||||||
gr[i] = grpc(QUIET_MS[i]);
|
|
||||||
ws[i] = websocket(QUIET_MS[i]);
|
|
||||||
}
|
|
||||||
row("RSocket", rs);
|
|
||||||
row("gRPC", gr);
|
|
||||||
row("WebSocket", ws);
|
|
||||||
|
|
||||||
System.out.println();
|
|
||||||
System.out.println("RSocket is flat: the server produced exactly what request(n) asked for.");
|
|
||||||
System.out.printf("gRPC grows %.1fx between 0.5 s and 2 s; WebSocket grows %.1fx.%n",
|
|
||||||
gr[2] / (double) gr[0], ws[2] / (double) ws[0]);
|
|
||||||
System.out.println("Growth that tracks the wait, rather than settling at a buffer size,");
|
|
||||||
System.out.println("means nothing bounded the producer. (Growth is faster than linear");
|
|
||||||
System.out.println("because the loop is still being JIT-compiled during the first run.)");
|
|
||||||
|
|
||||||
// RSocket delivered exactly the demand, at every quiet period.
|
|
||||||
assertThat(rs).containsOnly(WANTED);
|
|
||||||
// The other two grew with the wait rather than settling at a buffer size.
|
|
||||||
assertThat(gr[2]).isGreaterThan(gr[0] * 2);
|
|
||||||
assertThat(ws[2]).isGreaterThan(ws[0] * 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void row(String label, long[] v) {
|
|
||||||
System.out.printf("%-14s %,10d %,10d %,10d%n", label, v[0], v[1], v[2]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RSocket: {@code request(n)} is a frame on the wire. The server is told the number, produces
|
|
||||||
* that many, and stops. Nothing blocks and nothing buffers — the producer is simply not
|
|
||||||
* called again.
|
|
||||||
*/
|
|
||||||
private long rsocket(long quietMs) throws Exception {
|
|
||||||
RSocketRequester requester = RSocketRequester.builder()
|
|
||||||
.rsocketStrategies(strategies).tcp("localhost", 7003);
|
|
||||||
try {
|
|
||||||
AtomicLong received = new AtomicLong();
|
|
||||||
BaseSubscriber<Quote> subscriber = new BaseSubscriber<>() {
|
|
||||||
@Override
|
|
||||||
protected void hookOnSubscribe(Subscription subscription) {
|
|
||||||
subscription.request(WANTED); // and never again
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void hookOnNext(Quote value) {
|
|
||||||
received.incrementAndGet();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
requester.route("quotes.unbounded").data("AAPL")
|
|
||||||
.retrieveFlux(Quote.class).subscribe(subscriber);
|
|
||||||
Thread.sleep(quietMs);
|
|
||||||
long produced = requester.route("produced").retrieveMono(Long.class).block();
|
|
||||||
subscriber.dispose();
|
|
||||||
assertThat(received.get()).isEqualTo(WANTED);
|
|
||||||
return produced;
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
requester.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* gRPC: there is no application-level demand signal. The blocking stub's {@code Iterator}
|
|
||||||
* requests one message per {@code next()}, and the handler writes with
|
|
||||||
* {@code StreamObserver.onNext}, which never blocks.
|
|
||||||
*/
|
|
||||||
private long grpc(long quietMs) throws Exception {
|
|
||||||
ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 9093)
|
|
||||||
.usePlaintext().build();
|
|
||||||
try {
|
|
||||||
var stub = QuoteServiceGrpc.newBlockingStub(channel);
|
|
||||||
QuoteRequest req = QuoteRequest.newBuilder().setSymbol("AAPL").build();
|
|
||||||
Iterator<com.ankurm.protocols.grpc.Quote> it = stub.streamUnbounded(req);
|
|
||||||
long received = 0;
|
|
||||||
while (received < WANTED && it.hasNext()) {
|
|
||||||
it.next();
|
|
||||||
received++;
|
|
||||||
}
|
|
||||||
Thread.sleep(quietMs);
|
|
||||||
return stub.produced(req).getCount();
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
channel.shutdownNow();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Raw WebSocket: the frame protocol has no notion of demand at all. */
|
|
||||||
private long websocket(long quietMs) throws Exception {
|
|
||||||
try (WsClient client = new WsClient(httpPort, WANTED)) {
|
|
||||||
client.send("UNBOUNDED AAPL");
|
|
||||||
long received = 0;
|
|
||||||
while (received < WANTED && client.take(5_000) != null) {
|
|
||||||
received++;
|
|
||||||
}
|
|
||||||
Thread.sleep(quietMs);
|
|
||||||
// Ask on a second connection: the first one's server-side thread is busy writing.
|
|
||||||
try (WsClient asker = new WsClient(httpPort, 8)) {
|
|
||||||
asker.send("PRODUCED");
|
|
||||||
return Long.parseLong(asker.take(5_000));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package com.ankurm.protocols;
|
|
||||||
|
|
||||||
import java.util.Arrays;
|
|
||||||
|
|
||||||
/** Latency bookkeeping, kept in one place so the three protocols are measured identically. */
|
|
||||||
final class Bench {
|
|
||||||
|
|
||||||
private final long[] nanos;
|
|
||||||
private int i;
|
|
||||||
|
|
||||||
Bench(int n) {
|
|
||||||
this.nanos = new long[n];
|
|
||||||
}
|
|
||||||
|
|
||||||
void record(long ns) {
|
|
||||||
nanos[i++] = ns;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** p50, p99 and mean in microseconds, plus calls per second derived from the mean. */
|
|
||||||
String summary(String label) {
|
|
||||||
long[] sorted = Arrays.copyOf(nanos, i);
|
|
||||||
Arrays.sort(sorted);
|
|
||||||
double p50 = sorted[(int) (sorted.length * 0.50)] / 1000.0;
|
|
||||||
double p99 = sorted[(int) (sorted.length * 0.99)] / 1000.0;
|
|
||||||
double mean = Arrays.stream(sorted).average().orElse(0) / 1000.0;
|
|
||||||
return String.format("%-12s n=%-6d p50=%8.1f us p99=%9.1f us mean=%8.1f us ~%,.0f calls/s",
|
|
||||||
label, sorted.length, p50, p99, mean, 1_000_000.0 / mean);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user