Kafka connector

Worked examples

Two complete Kafka connector configurations with the workflows that consume them - order events given a lane per order, and call events threaded into one session across regional topics.

Two configurations taken end to end, each with the workflow it feeds. Both assume you have read Configuration; neither needs anything from the other.

Order events, one order at a time

An e-commerce team publishes order lifecycle events to orders.events, keyed by order id, with the event type inside the payload. They want no two events for the same order applied at once, different orders handled in parallel, and no run at all for a page view.

duraton:
  app: orders
  topics: [orders.events]
  decoder: json
  envelope: wrapped
  filter: 'event.data.value.type != "order.viewed"'
  mapping:
    eventName: value.type
    tags:
      region: value.region

kafka:
  bootstrap.servers: kafka-1:9092,kafka-2:9092,kafka-3:9092
  group.id: duraton-orders
  auto.offset.reset: latest
  isolation.level: read_committed

eventName: value.type means one topic feeds many workflows: a record whose payload says "type": "order.paid" triggers whatever subscribes to order.paid.

Giving each order a lane of one is a line of workflow config. With the wrapped envelope the Kafka record key is at key, so concurrency keyed on it admits one run per order at a time while different orders run fully parallel:

type OrderEnvelope = {
  key: string;
  offset: number;
  value: { type: string; orderId: string; total: number };
};

export const orderPaid = defineWorkflow<OrderEnvelope>({
  name: "order.paid",
  concurrency: { limit: 1, key: "key" },
  handler: async (ctx) => {
    const { key: orderId, value: order, offset } = ctx.event.data;
    ctx.log.info("applying order event", { orderId, offset });

    const invoice = await ctx.step.run("invoice", () => raiseInvoice(order));
    await ctx.step.run("notify", () => emailCustomer(orderId, invoice));
    return invoice;
  },
});

The filter drops order.viewed before any event is sent, so the noisiest record on the topic never becomes a run and never costs anything.

A concurrency key buys exclusion, not sequence. One order's runs never overlap, but two runs already queued behind the same key are admitted in whatever order the queue offers them, not in the order the partition delivered them. A refused run is simply retried a moment later, and nothing tiebreaks on creation time.

That is enough for work that must not collide (reserving stock while capturing payment against the same order) and not enough for work that must be applied in sequence (a state machine that rejects order.shipped before order.paid). When sequence is the requirement, make the workflow itself tolerate arriving early: read the current state, and park or reject rather than assume the previous event has landed. The record's offset is in the wrapped envelope precisely so a workflow can tell how far along the partition an event sat.

Call events across regional topics

A voice platform writes call lifecycle events to one topic per region, over a mutually authenticated cluster. Records are not keyed, so ordering has to come from the payload. Runs for one call should thread into a single session.

duraton:
  app: voice
  topicPattern: '^voice\.calls\.(eu|us|apac)$'
  decoder: json
  envelope: wrapped
  mapping:
    eventName: value.event
    session: value.callId
    tags:
      region: value.region
      trunk: value.trunk

kafka:
  bootstrap.servers: kafka-1.voice.internal:9093,kafka-2.voice.internal:9093
  group.id: duraton-voice
  auto.offset.reset: earliest
  security.protocol: SASL_SSL
  sasl.mechanism: SCRAM-SHA-512
  sasl.username: duraton
  ssl.ca.location: /etc/duraton/ca.pem
  max.poll.records: 200

Two things differ from the first example beyond the domain. The subscription is a pattern, and it is anchored: ^voice\.calls\.(eu|us|apac)$ matches those three names and nothing else, so a new voice.calls.dlq topic does not silently join the subscription. And because the producer does not set a record key, per-call ordering is keyed on a payload path instead:

type CallEnvelope = {
  topic: string;
  value: { event: string; callId: string; region: string; durationMs: number };
};

export const callCompleted = defineWorkflow<CallEnvelope>({
  name: "call.completed",
  concurrency: { limit: 1, key: "value.callId" },
  handler: async (ctx) => {
    const { callId, durationMs } = ctx.event.data.value;
    ctx.log.info("rating call", { callId, topic: ctx.event.data.topic });

    const rated = await ctx.step.run("rate", () => rateCall(callId, durationMs));
    await ctx.step.run("post-cdr", () => postCDR(rated));
    return rated;
  },
});

session: value.callId threads every run started by that call - setup, answer, completion - into one session, so the console shows a call as one conversation rather than as three unrelated runs. max.poll.records: 200 is lowered from the default because these records are large; the startup poll-window check will tell you if the number you pick cannot hold.

Next

On this page