Columnar processing
The columnar package processes Kafka records as Apache Arrow batches
instead of one record at a time. It is meant for analytical work where
per-record dispatch dominates the cost.
The batch model
One fetched topic-partition batch is one processing unit. Records arrive from
a consumer poll, are decoded into a single arrow.Record, flow through
operators as whole batches, and are encoded back into records at a sink.
The processor instances created when a topology is built survive across calls
to RunBatch, so GroupBy accumulates across batches, and custom processors
can keep state. Processor instances are isolated by logical partition number.
Reserved metadata columns
Every decoded batch carries five columns that hold Kafka record metadata, appended after the payload columns:
| Column | Arrow type | Contents |
|---|---|---|
_ |
Binary, nullable |
record key bytes, null for a keyless record |
_ |
Int64 |
record timestamp |
_ |
Int32 |
source partition |
_ |
Int64 |
source offset |
_ |
Binary |
ordered Kafka headers, including null values |
A colliding payload name is escaped in the processing batch;
columnar.PayloadColumn(name) returns that name. The sink restores the
original payload name before encoding. The names are exported as
KeyColumn, TimestampColumn, PartitionColumn, OffsetColumn, and
HeadersColumn.
Codecs
type BatchCodec interface {
Decode(topic string, records []ConsumedRecord) (arrow.Record, error)
Encode(topic string, batch arrow.Record) ([]ProduceRecord, error)
}
BlobCodec, for records that are already Arrow
Use NewBlobCodec(mem) when producers write Arrow IPC streams as record
values. Decoding reads each value as an IPC stream, attaches metadata
columns, and concatenates the results; all records in a batch must share one
payload schema. Encoding drops the metadata columns and packs the largest
consecutive rows that fit under DefaultMaxRecordBytes (900 KiB) and share
one key, timestamp, and header list. A single row over the cap fails instead
of sending a record the broker will reject.
RowCodec, for ordinary Kafka records
codec := columnar.NewRowCodec[string](valueSerde, columnar.NewJSONRowBridge[string](), mem)
Decoding deserializes each value with the supplied ValueSerde, converts
the values into columns through a RowBridge, and attaches metadata.
Encoding reverses it; row count is preserved in both directions. Wrap any
codec in NewGzipBatchCodec for per-record GZIP compression (16 MiB
decompression ceiling by default).
JSONRowBridge
Routes values through JSON. Column inference uses the first non-null sample
and is retained across batches; pin the schema with
NewJSONRowBridgeWithSchema or derive it from JSON Schema with
JSONRowBridgeFromJSONSchema, which also enforces required fields. Nested
objects and arrays become JSON text columns tagged krabka.json. Scalar row
types are wrapped in a single column named value.
Registry-backed codecs
columnarschema.NewAvroBatchCodec and NewProtobufBatchCodec implement
BatchCodec with columns that follow the record schema: nested records
become Struct, arrays List, maps Map, and decimals and timestamps
their native Arrow types. The Arrow schema derives from the reader schema
once, at construction (ArrowSchema()); writer schemas resolve onto it, and
an unknown writer schema ID surfaces as the cache’s retriable pending-fetch
error. See the package documentation for the tagged fallbacks (multi-branch
unions, recursive shapes, wrappers, google.protobuf.Timestamp).
IPCSerde
NewIPCSerde(mem) reads and writes single batches in the Arrow IPC stream
format. Each serialized value contains exactly one record batch; the caller
releases what Deserialize returns.
Operators
type Processor interface {
Process(ctx *Context, batch arrow.Record) error
}
Context.Forward emits one output batch; zero calls drop the input, several
fan it out. Built-ins always forward exactly one batch:
Filter(mem, predicate)keeps passing rows in order, metadata included.Select(mem, columns...)keeps the named payload columns and appends whichever reserved metadata columns exist. A missing name fails withArrow column does not exist: sku.WithColumns(mem, derived...)adds derived columns; matching names replace in place, new names append. Reserved names are rejected at construction. Returned values are coerced to the declared Arrow type throughcolumnar.AppendValue.GroupBy(mem, keys, aggregations...)groups cumulatively across batches; output is ordered by first appearance, keys first.CountyieldsInt64;Sumaccumulates exactly for integral inputs (overflow fails) and as float64 for floats;Min/Maxskip nulls. Metadata columns are dropped unless you group by them.WindowedGroupBy(mem, keys, windowSize, aggregations...)splits the same aggregation into fixed event-time windows, reading__timestampand adding__window_startand__window_end. Closed windows are retained for one window size, or the retention passed toWindowedGroupByWithRetention.
Custom stateful processors implement StatefulProcessor to participate in
partition snapshot and restore.
Buffer ownership
Arrow buffers are reference-counted. The rules for a topology are short: the
input batch belongs to the framework; forwarding a batch transfers it to the
framework; a batch you create and do not forward is yours to release. All
intermediate batches are released before RunBatch returns, including on the
error path. Outside a topology, whatever a method returns to you, you
release. In tests, memory.NewCheckedAllocator turns a leak into a failure.
Topologies
topology := columnar.NewTopology(mem)
source, err := topology.AddSource("source", []string{"transactions"}, codec)
large, err := topology.AddOperator("large", columnar.Filter(mem, predicate), source)
_, err = topology.AddSink("archive", "large-transactions", codec, large)
_, err = topology.AddSink("audit", "audit-log", codec, large) // fan-out
built, err := topology.Build()
AddProcessor takes a factory invoked once per logical partition; AddMerge
concatenates same-schema branches; AddJoin performs a stateful
co-partitioned inner equi-join within an event-time window;
AddPassThroughSink copies source records byte-for-byte. Build validates:
unique names, parents added earlier (cycles are unrepresentable), at least
one source and one sink.
RunBatch(topic, records) evaluates nodes in order and returns the produced
records; RunBatches evaluates several source topics together for fan-in.
Partitions run with isolated processor state; SnapshotPartition,
RestorePartition, and ReleasePartition expose the lifecycle. A built
topology serializes concurrent calls; use one per goroutine for parallelism.
The runner
RunPartitionOnce runs one explicit assign–seek–poll–process–send–commit
cycle. For automatic partition discovery, create a group runner:
runner, err := columnar.NewGroupRunner(topology, consumer, producer,
columnar.WithErrorPolicy(columnar.DeadLetterPolicy("dlq")),
columnar.WithStateStore(columnar.NewFileStateStore(dir)),
columnar.WithMetrics(metrics))
for running {
offsets, err := runner.RunOnce(ctx, 250*time.Millisecond)
}
Consumer and Producer are small interfaces; adapters over franz-go or
confluent-kafka-go are a few dozen lines. The runner implements
RebalanceListener: newly assigned logical partitions restore from the state
store, revoked partitions save before release, lost partitions release
without saving. RunOnceTransactional sends produced records and consumed
offsets in one producer transaction through the TransactionalProducer
interface.
Failed partition state is rolled back before the error policy applies, and
retriable errors (anything whose chain has Retriable() bool returning
true, such as the schema cache’s pending fetch) always fail the poll so the
records are retried. Dead letters carry krabka.error.class,
krabka.error.message, krabka.source.topic, krabka.source.partition,
and krabka.source.offset headers.
WithBarrierGroup aligns the runner on the cuts a krabka broker publishes,
snapshots the state of every partition at each cut, and commits the cut
offsets. RestoreToEpoch puts the runner back at one of those cuts. See
barrier cuts.