Skip to content

Broker topics

A view or a consumer can read from a broker topic instead of an entity, and a consumer can publish to one. Topics are how an ankka service exchanges messages with systems outside it, including other ankka services and services written with no ankka at all. Kafka is the broker ankka ships with.

Reading from a topic

Declare a topic as the source, with the serializer that decodes its messages:

Scala

import com.thinkmorestupidless.ankka.core.{Codecs, ComponentId}
import com.thinkmorestupidless.ankka.sdk.*

final case class StockEvent(productId: String, delta: Int)
final case class StockRow(productId: String, level: Int)

final class StockLevelsView extends View[StockEvent, StockRow]:
  def onChange(event: StockEvent): Effect =
    val current = rowState.getOrElse(StockRow(updateContext.subject, 0))
    effects.updateRow(current.copy(level = current.level + event.delta))

object StockLevels
    extends View.Companion[StockLevelsView, StockEvent, StockRow](
      componentId = ComponentId("stock-levels"),
      source = ChangeSource.fromTopic("stock-events", Codecs.serializer[StockEvent]("stock-event")),
      rowSerializer = Codecs.serializer[StockRow]("stock-row")
    ):
  def create(ctx: ViewComponentContext) = new StockLevelsView

Python

from dataclasses import dataclass, replace

from ankka import json_codec
from ankka.effects.view import ViewEffect
from ankka.view import View


@dataclass(frozen=True)
class StockEvent:
    productId: str
    delta: int


@dataclass(frozen=True)
class StockRow:
    productId: str
    level: int = 0


class StockLevels(View[StockEvent, StockRow]):
    component_id = "stock-levels"
    topic = "stock-events"
    event_codec = json_codec(StockEvent, "stock-event")
    row_codec = json_codec(StockRow, "stock-row")

    def on_change(self, event: StockEvent) -> ViewEffect:
        current = self.row or StockRow(self.metadata.subject or "")
        return self.effects.update_row(replace(current, level=current.level + event.delta))

A consumer reads a topic the same way: ChangeSource.fromTopic(...) in Scala, topic = "..." in Python.

The view's row is keyed by the message's CloudEvents subject, the ce-subject header, falling back to the Kafka record key when the header is absent. A message with neither is skipped by a view rather than retried, because there is no row it could belong to.

Connecting to Kafka

A Scala service names the broker with the projection runtime:

Ankka.service
  .register(StockLevels.descriptor)
  .withExtension(ProjectionRuntime.withKafka("localhost:9092"))
  .start()

A component that reads or publishes a topic in a service with no broker configured is refused at startup, rather than started and never delivering anything.

A Python service's sidecar connects to the broker named by ANKKA_KAFKA_BOOTSTRAP_SERVERS. Set it in the service descriptor's env, where the platform routes it to the sidecar. Without it the sidecar refuses to start, naming the component that needs a broker.

Message format

ankka frames messages as CloudEvents in binary mode: the body is the plain encoded message, and the CloudEvents attributes travel beside it as Kafka headers. A consumer written in any language, with or without ankka, reads an ordinary JSON body and finds the metadata in the headers.

Header Value when ankka publishes
ce-specversion 1.0
ce-id a new random UUID for each publication
ce-type message
ce-subject the source entity's id
content-type application/json

A header set through the metadata passed to effects.produce(message, metadata) replaces the default of the same name, and any other metadata is sent as additional headers.

Ordering

ce-subject is also the Kafka record key. Kafka keeps order only within a partition and assigns a key to one partition, so every message about one entity is published to one partition and read in the order it was written. Messages about different entities have no order relative to each other.

This is why the subject matters when publishing. A consumer publishing about a cart publishes under the cart's id by default; one that sets its own ce-subject chooses the ordering unit by doing so.

Delivery and offsets

Reading a topic is at least once. Offsets are committed to Kafka after the handler has returned, so a restart or a failure redelivers what was not yet committed, and a topic-sourced component must tolerate seeing a message twice.

Partitions are assigned by Kafka consumer groups, one group per component. Two components reading one topic each see every message; the instances of one service share each component's partitions between them, and Kafka rebalances them as instances come and go with no configuration in ankka.

A topic is not a journal. A component reading a topic sees only what is published after it starts, and it cannot rebuild its state from history, because a broker's retention is not a complete record. A view that must be rebuildable belongs over an entity's events.

Testing without a broker

InMemoryBroker is a publisher and a subscriber wired to each other. It exercises the whole topic path — headers, subject keying, decoding, view writes and consumer dispatch — and substitutes only the network:

import com.thinkmorestupidless.ankka.core.Metadata
import com.thinkmorestupidless.ankka.runtime.{InMemoryBroker, ProjectionRuntime}

val broker  = InMemoryBroker()
val testKit = AnkkaTestKit.start(Seq(StockLevels.descriptor), Seq(ProjectionRuntime.withBroker(broker, broker)))

broker.publish("stock-events", """{"productId":"p1","delta":5}""".getBytes("UTF-8"), Metadata.empty.withSubject("p1"))

broker.publishedTo(topic) returns what components published, for assertions. InMemoryPublisher is the publishing half alone, for a service that only publishes.

Other brokers plug in through the same two interfaces, MessagePublisher and MessageSubscriber, in com.thinkmorestupidless.ankka.runtime; pass implementations to ProjectionRuntime.withBroker.