Skip to content

stream processing in one binary

kayak is a stream processor written in Rust. You write pipelines in a config file and keep the file in version control. You run the container image with that file. That is the complete deployment.

$ docker run -p 6767:6767 -v "$PWD:/kayak" \
    ghcr.io/niclasgrahm/kayak --config /kayak/config.yaml

one rust binary · JSON or YAML · no cluster · no database of its own

nats → filter → clickhouse config.yaml
- id: warm_sensors
  inputs:
    - type: nats
      connection: plant-nats
      subject: sensors.>
      max_batch: 500
  transforms:
    - type: filter
      conditions:
        - { type: numeric, field: value,
            operator: greater_than, value: 30 }
  outputs:
    - type: clickhouse
      connection: analytics
      table: warm_readings
A pipeline is inputs → transforms → outputs. The names plant-nats and analytics are connections. You declare them one time, in config.connections.yaml beside this file.
  1. how it works
  2. a pipeline config
  3. inputs step 1
  4. transforms step 2
  5. outputs step 3
  6. the graph zoom out

a pipeline is three lists

A pipeline is inputs → transforms → outputs, and all three are arrays. kayak merges the inputs into one stream. The transforms run in the order of the config. Each output gets every batch.

The card on the right shows one pipeline. The web UI draws it from the same config: the config in three tabs, a throughput chart, and the batches that went through.

inputs: where the messages come from

An input reads a subject, a topic, a channel, a table or a set of OPC UA nodes. The http input receives posts. Each input gives the pipeline batches of plain JSON. You do not declare a schema.

Every input can buffer by count, by time window, or both. Every input can add an envelope with its metadata, for example the subject, the partition or the offset. The envelope is ordinary JSON fields.

natskafkamqttredisopcuahttphttp_pollpostgresclickhouseindupipelinedummy

transforms: what happens between

A transform is a small step that does one thing. filter keeps or drops messages. map copies, casts and calculates fields. reducer aggregates with group_by. The streaming transforms keep state per key. Use script when configuration is not sufficient.

Every transform addresses fields by path, for example sensor.id or _meta.subject. Each transform has a policy for a missing field. kayak does not guess.

filtermapreducersplitterbufferrememberrecallscripthttpdeadbandthrottlepivotderiverollingsmoothdetectresamplefeatures

outputs: where the messages go

Each output gets every batch. To archive to postgres and also send to kafka, use one pipeline with two outputs. The database outputs map fields to typed columns. The file and s3 outputs rotate parts by rows or by time.

kayak retries an output that it cannot reach at startup. If postgres starts 30 seconds after kayak, the pipeline waits and then continues.

postgresclickhouses3filekafkanatsmqttredishttpindutidepoolstdout

pipelines feed pipelines

The pipeline input reads the output of another pipeline. One pipeline reads the broker one time. Many pipelines downstream use its messages: a rollup, an archive, an alert. Your config is a graph of small pipelines.

Each pipeline has its own transforms and outputs. kayak shares each message between them and does not copy it. In the web UI, a line lights up when a batch goes along it.

performance

the runtime is not the bottleneck

kayak has no garbage collector. The just bench harness measures the cost of the run loop. These numbers come from an Apple M1 Max, in process, with no network and no disk. They exclude I/O, so they are not end-to-end throughput.

7Mpasses/s

one pipeline, no transforms

31Mmessages/s

one pipeline, batches of 100, one filter

5.6Bmessages/s

1000 pipelines at the same time, batches of 100, 14 MiB resident

9MiB

resident memory of one pipeline

  • Fan-out does not copy. Each output and each downstream pipeline gets the same shared batch (Arc).
  • Batches share the cost. Set max_batch on a broker input, or an input buffer. kayak then does the work per batch one time for many messages.
  • Databases get batches. The clickhouse output writes one insert per batch.
  • No browser, no UI cost. A server with no web UI attached does no work for the UI.
composability

small parts that connect

Each transform does one thing. You connect transforms in a chain, and you connect pipelines in a graph. A message is plain JSON from the input to the output. There is no schema to declare.

sensorsNATS · sensors.>sensors_10s_avgBUFFER · REDUCER → CLICKHOUSEsensors_archiveS3 · NDJSONhot_alertsFILTER → HTTP
  • Many inputs, many outputs. kayak merges the inputs of a pipeline into one stream. Each output gets each batch.
  • Pipelines feed pipelines. The pipeline input reads the output of another pipeline. You can make fan-out, fan-in and chains of any depth.
  • Connections. You declare a broker, a database or a bucket one time, with a name. Many components refer to that name.
  • State buckets. remember writes a value to a named bucket. recall reads it, also in a different pipeline.
  • Metadata is data. An input can add the subject, topic or offset to the message as ordinary JSON fields. Each transform can then use them.
  • Scripts when you need them. Use script (rhai) when the other transforms are not sufficient.
a pipeline fed by a pipeline config.yaml
- id: sensors_10s_avg
  inputs:
    - type: pipeline
      upstream: sensors
      buffer: { type: tumbling, window_seconds: 10 }
  transforms:
    - type: reducer
      group_by: [sensor, _meta.subject]
      aggregations:
        - { function: avg,   field: value, as: mean }
        - { function: max,   field: value, as: highest }
        - { function: count, as: readings }
  outputs:
    - type: clickhouse
      connection: analytics
      table: sensor_rollups
      order_by: [sensor]
      columns:
        - { name: sensor,   type: text }
        - { name: mean,     type: float }
        - { name: highest,  type: float }
        - { name: readings, type: bigint }
The reducer sends one message for each (sensor, subject) pair every 10 s. kayak checks the column mapping when it builds the pipeline.
declare a system one time config.connections.yaml
plant-nats:
  type: nats
  urls: nats://nats.internal:4222
analytics:
  type: clickhouse
  url: https://ch.internal:8443
  database: plant
  user: kayak
  password: ${CLICKHOUSE_PASSWORD}
archive:
  type: s3
  bucket: events
  region: eu-north-1
  access_key_id: ${S3_KEY_ID}
  secret_access_key: ${S3_SECRET}
A connection holds what the system is: hosts, URLs and credentials. A component adds only what it wants from the system, for example a subject or a table. kayak reads ${NAME} from the environment or from a secrets file.
feature completeness

the components

The reference documents each component and each field. kayak generates the reference from the config types, so it agrees with the server that you run.

inputsnatskafkamqttredisopcuahttphttp_pollpostgresclickhouseindupipelinedummy
transformsfiltermapreducersplitterbufferrememberrecallhttpscriptdeadbandthrottlepivotderiverollingsmoothdetectresamplefeatures
outputspostgresclickhouses3filekafkanatsmqttredishttpindutidepoolstdout
connectionskafkanatsmqttredispostgresclickhouses3fileopcuaindutidepool
buffers

Each input can buffer by count, by time window, or by the first of the two.

acknowledgement

With ack: on_delivery, a kafka or mqtt input acknowledges a message after the outputs return.

column mapping

The postgres and clickhouse outputs map fields to typed columns and create the table.

rotation

The file and s3 outputs rotate parts by row count or by time.

streaming statistics

Rolling windows, smoothing, rates, resampling and anomaly detection, per key.

model calls

The http transform sends messages to a service. It can merge the reply into each message.

industrial

The opcua input subscribes to nodes and sends one message for each value change.

ingest endpoint

The http input gives a pipeline its own endpoint, with an optional credential.

operation

run it like any other service

The container image contains the binary and nothing else. You mount your config and name it on the command line. The arguments of the container are the flags of the server.

run it
$ docker run -p 6767:6767 -v "$PWD:/kayak" \
    -e CLICKHOUSE_PASSWORD \
    ghcr.io/niclasgrahm/kayak \
      --config /kayak/config.yaml \
      --server-config /kayak/server.yaml
kayak finds config.connections.yaml beside the config, so mount the directory. The image is for linux/amd64 and linux/arm64 and runs as uid 10001. In Kubernetes, put the config in a ConfigMap and the secrets in environment variables.
the settings of the server server.yaml
auth:
  type: basic
  users:
    ops:    { password: ${OPS_PASSWORD},    role: admin }
    viewer: { password: ${VIEWER_PASSWORD}, role: read }
history:
  retention_secs: 86400     # 0 turns history off
Authentication has two roles. admin can change the graph. read can only look. Accounts come from this file (basic) or from an identity provider (jwt).
flags kayak --help
--config <path>The pipelines, as JSON or YAML. The file extension sets the format.
--connections <path>The connections file. Without the flag, kayak uses <config>.connections.<ext> beside the config.
--secrets <path>A JSON file of values for ${NAME}. kayak reads the environment first. kayak does not start if a name has no value.
--data-dir <path>The directory for file outputs. Without it, file outputs do not build.
--server-config <path>Authentication and history. Without it, the server authenticates nobody.
--listen <addr>The bind address. The default is 127.0.0.1:6767. The image binds 0.0.0.0:6767.
--debugMore log output.
the http api
# send messages to a pipeline with an http input
$ curl -X POST localhost:6767/api/pipelines/ingest/messages \
    -d '[{"sensor":"line1/temp","value":21.5}]'
HTTP/1.1 202 Accepted

# counters and failure records of the last day
$ curl localhost:6767/api/pipelines/ingest/history?resolution=coarse

# the complete api, as OpenAPI 3.1
$ curl localhost:6767/api/openapi.json
kayak builds its routes and the OpenAPI document from one table. /api/docs gives the component reference as JSON.
limits

what kayak does not do

Read these limits before you choose kayak for a job.

one process

kayak does not cluster. It has no distributed state and no exactly-once delivery across machines.

state in memory

State buckets and history are in memory. A restart clears them.

no table migrations

The postgres and clickhouse outputs create a table if it does not exist. They do not change an existing table.

pre-1.0

The config format can change between minor versions. The roadmap is in the repository.

optional

web ui

The server also has a web UI. Use it to look at the running graph and the messages in each pipeline. You do not need it to run kayak. Read about the web UI.

try it
$ docker run --rm -p 6767:6767 --entrypoint sh ghcr.io/niclasgrahm/kayak -c \
    'echo "[{id: ticker, inputs: [{type: dummy, duration: 1}], outputs: [{type: stdout}]}]" > c.yaml && exec kayak --config c.yaml'

kayak is built with Rust, Axum, Tokio and Leptos. The name is lowercase.