DEV Community

alginte software for alginte

Posted on Originally published at alginte.com on

A Kafka Streams topology starts in a sandbox, not on your cluster

Somebody owns the Kafka cluster you need, and it is not you. You have a topology in mind: read orders, keep the big ones, write them somewhere. You have a predicate in mind too, and you are fairly sure it is right. "Fairly sure" is the problem. To find out, you need to run it, and the only place to run it is the shared cluster: a consumer group in everyone's list, internal topics with your name on them, and an output topic that real consumers may already be reading. Kafka does not take records back. A wrong predicate there means wrong records that somebody downstream has already acted on.

So you do not run it. The idea is shelved, and you never learn that it was the right one.

This post is about the other order: find out first, in a place where being wrong costs nothing, and only then touch the cluster. It needs two things. A stack of your own that you are free to break, and a way to carry what you learned across the border without carrying the stack with it.

The expensive part is not the topology

Writing a Kafka Streams topology is not where the time goes. The time goes into the question the code cannot answer for itself: does the predicate do what you meant, on the records that actually arrive? We made that argument at length in The compiler was never what you wanted: the cost is in the loop between having an idea and finding out, and on a shared cluster that loop is long, so you run it rarely.

The loop is short when three things are true. The records you evaluate against are real. The evaluation you see before deploying is the one that will run after. And being wrong leaves nothing behind. The earlier post was about the first two. This one is about the third, and about what happens after you are right.

A stack of your own, free to break

One docker compose up -d gives you a single-node Kafka, a Schema Registry, and a console that runs Kafka Streams topologies, bound to your machine, with no volumes. It comes seeded with topics, real Avro records against a registered subject, and a topology already running, so the first thing you see is a canvas with numbers on it. docker compose down returns the machine to the state it was in. That is the whole safety story. Nothing here can hurt anyone, because nothing here is shared.

Seeded orders are fine for the first ten minutes. After that you want your own topic. Say it is shipments, with JSON Schema values. Two folders beside the compose file take it in, and because they are read at every up, what you put there survives the down that erases everything else:

playground/
  docker-compose.yml
  schemas/
    shipments-value.json the subject, saved out of your registry
  records/
    shipments.json-schema.jsonl a sample of the topic, one record per line

Enter fullscreen mode Exit fullscreen mode

The file name is the contract. shipments-value.json is registered as the subject shipments-value; .avsc and .proto do the same for Avro and Protobuf. shipments.json-schema.jsonl is produced into the topicshipments, each value framed against that subject, and a line is either a keyed record or a bare one:

s-1|{"shipmentId":"s-1","orderId":"ord-1001","carrier":"DHL","weightKg":2.4,"destination":"Zagreb"}
s-2|{"shipmentId":"s-2","orderId":"ord-1003","carrier":"UPS","weightKg":11.0,"destination":"Milano"}

Enter fullscreen mode Exit fullscreen mode

Keep the topic and subject names identical to the real ones, because the design you are about to make carries them. A small sample is enough, if it is the right one: the ordinary record, and then the ones that break things. A null where a field is optional, an empty list, a value sitting on the boundary of your predicate, a key you did not expect. What you are proving is logic against the shape of your data, and shape is a matter of variety, not volume.

Production may not be able to give you that sample. The ordinary record is there a million times over; the one that breaks your predicate may not have happened yet, and a shared topic is no place to produce it to find out, with everyone else's consumers downstream. Here it is one more line in the file: written by hand, framed against the same subject as the sampled ones, seen by nobody's consumer but yours.

Anything personal in the sampled records is yours to redact before it lands in the folder. The playground's repository has these two files as templates to copy.

Now the loop. Drop a source node on shipments, bound to shipments-value. Add a filter, and start typing the predicate. As you type, it is evaluated against the newest record on the topic, and the result sits next to the editor: the input record, the output, the verdict. Put a mapValues in front of it and the filter sees the shape themapValues produced, not a guess. Misspell a field and the editor tells you on the keystroke. On an Avro or Protobuf record the access fails, with the same message the deployed operator would throw, because it is the same evaluation. On a JSON Schema record, where a missing key is a quietnull at runtime, the editor warns on the name and offers the closest one the subject declares. This is the short loop: the few seconds between the typo and finding it.

The filter's drawer on a JSON Schema source, the expression value.get('carier') == 'DHL' underlined as a warning. The status panel reads: the subject declares no property 'carier', lists the five declared properties, says an absent key answers null so this never fails here or deployed, and asks: did you mean 'carrier'? Under it the sample preview shows a real shipment and the verdict: predicate would DROP the sample.

Fix the name, finish the predicate, and the same panel judges it against a record you brought:

The same drawer with the two-line predicate value.get('carrier') == 'DHL' && value.get('weightKg') < 5 and no issues. The sample preview shows a real record from the shipments topic, key s-3, a DHL parcel of 1.1 kg for Berlin, in and out, with the verdict: predicate passes the sample.

Submit, and a gate runs before anything deploys: a source topic that does not exist, a source whose subject the registry does not have, join inputs that are not co-partitioned, a windowed key on its way to a sink that cannot write it. Each refusal names the node and the fix. Pass the gate, and the stream runs here, on this broker. Produce a few records, written by hand or generated at random from the subject's schema, one click each. Watch the sink fill, open the State tab and see the threads, the stores and the lag. Delete it, keep the state or remove it. Deploy it again under the same id, and the console tells you what the earlier run left on the cluster, a changelog and a committed offset, and asks whether to continue on it or start clean.

None of this is a simulation. It is the Kafka Streams client, running your topology, on a broker you can throw away. The console in the loop is Alginte, in the mode it callsthe playground, and the one claim that makes the loop worth trusting is the one we test hardest: an expression that evaluates one way in the editor evaluates the same way deployed, on Avro, JSON Schema and Protobuf alike. That promise is held by a corpus of rows that run each expression on both surfaces and refuse to ship a disagreement.

Carrying it across the border

A design that works here has to reach the cluster that matters, and it must not bring the sandbox with it.

The design travels as a file. Download writes the whole thing: the configuration, every node with its expressions and serdes, the edges, the layout. The file is named after the application.id, and it belongs in git, beside the code it feeds. It is plain, indented JSON, so a changed expression is a one-line diff, a new node is a block a reviewer can read in a pull request, and the history of the stream is the history of the file:

   "data": {
     "name": "light DHL parcels",
     "expression": {
- "expression": "value.get('carrier') == 'DHL' && value.get('weightKg') < 5",
+ "expression": "value.get('carrier') == 'DHL' && value.get('weightKg') < 10",
       "language": "SpEL"
     }

Enter fullscreen mode Exit fullscreen mode

That is what makes it the durable artefact: the sandbox is thrown away, the file is not.

What the file does not carry is as important: not the records, not the topic, not the schema, not the stream's state. Those are the cluster's, on both sides.

On the console that runs against the real cluster, Upload the file. The same wizard opens with the same validation. Three things are different at the border, and each is handled by a rule rather than by remembering.

A design carries its logic, not its cluster. If the file hasbootstrap.servers, a registry URL or security settings in it, the upload leaves them out and says which, so the connection that applies is this console's, never the sandbox's.

The builder's canvas just after an upload: the three nodes of the design, source, filter with its predicate, sink, and a notice beside them: Cluster-bound properties left out of the upload. bootstrap.servers, schema.registry.url: a design carries its logic, not the cluster it was written on. This console's own values apply; set them on Stream Properties if this stream needs others.

Names are checked against this cluster. The gate refuses a source topic that does not exist here, and a source whose subject this registry lacks, naming the node. Register the subject first, with the same text you put inschemas/, and the refusal goes away.

The id is checked against this cluster's memory. If a stream under thisapplication.id has run here before, the console says so before deploying: these internal topics, this committed offset. Continue on them, or start fresh. It never guesses.

Then Submit. The deploy is the same gate and the same client. The only thing that changed is the cluster it joined.

That is one of two routes out of the sandbox, and neither is the lesser one. The other: your team ships its streams as Java services, with a compiler and a test suite, and what you carry across is the shape, the expressions and the evidence that they are right. Either way you arrive having already answered the question the compiler cannot.

What the sandbox does not prove

Scale. A design proven on a small, well-chosen sample is proven for what it does to a record, not for its throughput, its partition layout under load, or how long its stores take to restore after a restart. Those are questions for the real cluster, and there they have answers. In the sandbox, the State tab showed a handful of records moving. On the real cluster, the same tab shows what matters at scale: a restore as it replays, the threads and the tasks they hold, the lag of every store, and on the canvas the throughput of every node. It is the same console on both sides of the border: the workbench here, the instrument there. The sandbox tells you the logic is right. The first hour on the real cluster tells you the rest, and it is an hour you can watch rather than infer.

The order of operations

Start where being wrong is free. Bring the shape of your data, and a sample that covers its corners. Write the expression against a real record and let the editor tell you on the keystroke. Deploy on a broker you can throw away, and throw it away. Then, if you choose to run it as it is, carry the design across to the production cluster as a file, and let the gate on the other side check the names, the id and the connection. Deploy there once, on purpose.

The production cluster is still somebody else's. The difference is that your stream now arrives there with its logic already proven, and "fairly sure" left behind in the sandbox.

Top comments (0)