Schema Registry formats (Avro, Protobuf, JSON Schema)¶
Registry-framed values on the Kafka connector. A
format='avro','protobuf'or'json-schema'table reads and writes messages in the Confluent Schema Registry wire format, against Confluent Schema Registry or any registry that speaks its REST API (Redpanda, Karapace, Apicurio's compatibility endpoint). It is a value format, not a connector:connector='kafka'stays as it is.
Overview¶
Most enterprise Kafka estates frame their values for a schema registry: a
magic byte, a four-byte schema id, then Avro binary, Protobuf binary or JSON
text. The clink::schema_registry library (impls/schema_registry/)
implements that framing, the registry REST client behind it, and the three
value formats. The Kafka connector links it and offers the formats on its
string-channel factories, the ones the SQL planner emits for every Kafka
table.
The design keeps the engine's JSON path intact. A source decodes each
message into one JSON object text, exactly what a format='json' table
carries, so the planner's json_string_to_row_columnar bridge, projection
pushdown and the columnar decode all apply unchanged. A sink encodes the
row_to_json_string bridge's JSON rows as the last step before the
producer. The planner's only job is to keep the Row channel and pass the
format through; everything format-specific is the connector's.
flowchart LR
T["Kafka topic<br/>registry-framed values"] --> S["kafka_source_string<br/>decode to JSON text"]
S --> B["json_string_to_row_columnar"]
B --> Q["SQL operators"]
Q --> R["row_to_json_string"]
R --> K["kafka_sink_string<br/>encode + frame"]
K --> U["Kafka topic"]
S <-. "GET /schemas/ids/{id}" .-> REG["Schema Registry"]
K <-. "POST /subjects/{s}/versions" .-> REG
Dependency and version¶
| Component | Provenance | Version |
|---|---|---|
Apache Avro C++ (avrocpp), for format='avro' |
From-source pin in the Debian image (scripts/install-connector-deps.sh, checksum-verified) / brew (macOS, avro-cpp) |
1.12.1 (AVRO_CPP_VERSION in scripts/versions.env) |
libprotobuf + libprotoc (the compiler library, which parses .proto text), for format='protobuf' |
System package via apt (libprotobuf-dev, libprotoc-dev) / brew (protobuf) |
Not pinned by clink (3.21 in the image, current release on Homebrew; both are exercised) |
cpp-httplib (the registry client's transport, via clink::http_connector) |
Vendored | vendored |
format='json-schema' needs nothing beyond the library itself.
Enabling it¶
The library is built whenever clink::http_connector is (CLINK_WITH_HTTP,
default AUTO, effectively always). Each format is compiled in when its
dependency is found:
format='avro':CLINK_WITH_AVRO(AUTO/ON/OFF), the same knob as the Avro codec impl.format='protobuf':CLINK_WITH_PROTOBUF(AUTO/ON/OFF).AUTOlooks for the protobuf CMake config first (Homebrew), then the CMake module (Debian), and needsprotobuf::libprotocas well asprotobuf::libprotobuf.format='json-schema': always.
The configure log says which landed: clink::schema_registry - enabled (avro, json-schema, protobuf). A format a build lacks is refused by name at build (deploy) time, not at runtime, and clink --capabilities-json lists the compiled-in formats under the Kafka connector's formats. The CI image carries all three.
Wire format¶
| Bytes | Content |
|---|---|
| 0 | magic byte, always 0x00 |
| 1 to 4 | schema id, big-endian int32 |
| (Protobuf only) | message indexes: a zigzag-varint count, then that many zigzag-varint indexes into the schema's message list and nested-message lists; the common path [0] is written as the single byte 0x00 |
| rest | the encoded value: Avro binary, Protobuf binary, or JSON text |
Nested Protobuf types are counted as the descriptor lists them, so a synthesised map-entry type occupies an index like any other nested message.
Options¶
All options are WITH (...) options on the Kafka table (or BuildContext
parameters on the kafka_source_string / kafka_sink_string /
kafka_2pc_sink_string / kafka_upsert_sink_string factories). Secrets
may be given as env://VAR like every other connector option.
| Option | Applies to | Required | Default | Description |
|---|---|---|---|---|
format |
both | Yes | (none) | avro, protobuf or json-schema (json_schema and jsonschema are accepted spellings). Any other value is the plain JSON or text path. |
schema_registry_url |
both | Yes | (none) | http://host:8081, https://registry.example.com, or with a path prefix such as https://host/apis/ccompat/v7. Credentials in the URL's userinfo part (https://key:secret@host) are sent as basic auth. |
schema_registry_auth |
both | No | (none) | user:password, sent as Authorization: Basic. |
schema_registry_token |
both | No | (none) | A bearer token, used when schema_registry_auth is empty. |
schema_registry_verify_tls |
both | No | true |
false skips server-certificate verification on an https registry. |
schema_registry_timeout_ms |
both | No | 30000 |
Connect and read timeout for registry calls. |
decode_error |
source | No | fail |
What the source does with a message its format cannot decode (a wrong magic byte, an id the registry does not know, a payload the schema rejects). fail stops the job with the topic, partition, offset and reason. skip drops the record, logs the first occurrence and every thousandth, and continues. |
schema_registry_subject |
sink | No | <topic>-value |
The subject the sink registers under or reads from (the topic-name strategy). |
schema_registry_auto_register |
sink | No | true |
Derive a schema from the table's declared columns and register it under the subject; the registry returns the existing id when it is already there, so redeploys are idempotent. false uses the subject's latest registered version instead and fails at build time if there is none. |
schema_registry_record_name |
sink | No | derived from the subject | The Avro record name or Protobuf message name of a derived schema. The default strips a -value or -key suffix from the subject and replaces every character outside [A-Za-z0-9_] with _. |
schema_registry_namespace |
sink | No | clink |
The Avro namespace of a derived schema. |
schema_registry_message |
sink (Protobuf) | No | the first message | Which message of a registry-held Protobuf schema to write, as Outer.Inner for a nested one. The frame carries its index path. |
The sink talks to the registry when it is built (register or read the subject), so a wrong URL, bad credentials or an incompatible schema fails at deploy, before the first record. The source is lazy: ids arrive with the data, each is fetched once and cached for the life of the operator.
Type mapping¶
Decoding (source)¶
Every message becomes one JSON object keyed by field name.
| Schema type | JSON |
|---|---|
Avro record, Protobuf message |
object; a non-record top-level Avro value is wrapped as {"value": ...} |
null, boolean, int, long, float, double, string; proto3 scalars |
the JSON equivalent; int64 and uint64 are JSON integers, not strings; NaN and infinities become null |
Avro union |
the value of the branch that was written; null for the null branch |
Avro enum, Protobuf enum |
the symbol name |
Avro array, repeated |
array |
Avro map, Protobuf map |
object |
bytes, fixed |
base64 string |
Avro decimal (bytes or fixed) |
a decimal string with the schema's scale, for example "1234.56", which a DECIMAL(p,s) column ingests exactly |
Avro date |
"YYYY-MM-DD" |
Avro time-millis / time-micros |
"HH:MM:SS.fff" / "HH:MM:SS.ffffff" |
Avro timestamp-millis, -micros, -nanos (and the local- variants); google.protobuf.Timestamp |
integer epoch milliseconds (micros and nanos are scaled down), the unit clink's event-time functions and event_time_column take; declare the column BIGINT |
Avro uuid |
string |
Protobuf field with presence (optional, message, oneof member) that is unset |
omitted; a proto3 scalar without presence is always present with its default |
Protobuf field names are the names declared in the .proto, not the
lowerCamelCase JSON names. A schema's references (Protobuf imports, Avro
named types held under another subject) are resolved through the registry by
subject and version; google.protobuf.* well-known types need no reference.
Encoding (sink)¶
The sink's input is the row JSON the row_to_json_string bridge produces.
With schema_registry_auto_register (the default) the schema is derived
from the declared columns; every derived field is nullable, so a column the
row lacks encodes as null, and the sink refuses nothing a SELECT can
produce. Against a registry-held schema the mapping accepts what decoding
emits, plus a few conveniences:
- Keys the schema does not name are dropped (
__row_kindamong them). A field the schema requires and the row lacks is an error that names the field. - Timestamps take an integer (epoch milliseconds, scaled up to the schema's unit) or an ISO-8601 string (
2024-01-01T00:00:00.123Z); dates takeYYYY-MM-DDor a day count; times takeHH:MM:SS.fffor an integer. - Decimals take a number or a string and are encoded at exactly the schema's scale from the row's exact digits (the generic parse would round past 17 significant digits); a value with more fractional digits than the scale is refused rather than rounded.
- A JSON integer for an Avro union chooses
longoverdouble, a string choosesstringoverbytes; the first branch that accepts the value wins otherwise. - Protobuf:
int64fields accept numeric strings, enums accept names or numbers,bytestake base64, and a decimal column into astringfield keeps every digit.
Derived schemas map the declared columns as follows.
| SQL column | Avro | Protobuf (proto3) | JSON Schema |
|---|---|---|---|
BIGINT |
["null","long"] |
int64 |
["integer","null"] |
INT |
["null","int"] |
int32 |
["integer","null"] |
DOUBLE |
["null","double"] |
double |
["number","null"] |
FLOAT |
["null","float"] |
float |
["number","null"] |
BOOLEAN |
["null","boolean"] |
bool |
["boolean","null"] |
VARCHAR / TEXT (and any other type) |
["null","string"] |
string |
["string","null"] |
DECIMAL(p,s) |
["null",{"type":"bytes","logicalType":"decimal","precision":p,"scale":s}] |
string (proto3 has no exact decimal scalar) |
["number","null"] |
FLOAT ARRAY |
["null",{"type":"array","items":"float"}] |
repeated float |
["array","null"] of number |
A JSON Schema sink with a derived schema re-serialises each row with only the declared columns, in schema order, keeping decimal digits exact; with a registry-held schema it passes the row through as written.
SQL usage¶
CREATE TABLE orders (
id BIGINT,
name TEXT,
amount DECIMAL(18,2),
placed BIGINT -- an Avro timestamp-millis arrives as epoch ms
) WITH (
connector = 'kafka',
format = 'avro',
brokers = 'broker:9092',
topic = 'orders',
group_id = 'analytics',
schema_registry_url = 'https://registry.example.com',
schema_registry_auth = 'env://SR_AUTH', -- "key:secret"
event_time_column = 'placed',
watermark_lag_ms = '5000'
);
CREATE TABLE totals (
name TEXT,
total DECIMAL(18,2)
) WITH (
connector = 'kafka',
format = 'protobuf',
brokers = 'broker:9092',
topic = 'order-totals',
schema_registry_url = 'https://registry.example.com',
schema_registry_auth = 'env://SR_AUTH'
);
INSERT INTO totals SELECT name, SUM(amount) FROM orders GROUP BY name;
The sink registers a proto3 schema with one message, order_totals, under
the subject order-totals-value on deploy, and frames every value with the
id it got back. mode='upsert' and exactly-once (kafka_2pc_sink_string)
sinks take the same options; an upsert sink keeps its JSON-derived primary
key as the message key and frames only the value.
Programmatic use¶
impls/kafka/include/clink/kafka/string_channel.hpp builds the same source
and sink from a BuildContext the way the planner's factories do; the
library's own API is impls/schema_registry/include/clink/schema_registry/:
client.hpp (the registry client), wire_format.hpp (frame and parse),
formats.hpp (make_decoder / make_encoder, parse_format_options) and
schema_derivation.hpp.
Delivery semantics¶
Unchanged from the Kafka connector: the format is applied per record on the
way in and out and carries no state, so the source's offset replay, the
transactional sink's two-phase commit and the upsert sink's keying behave as
documented for Kafka. A decode failure under decode_error='fail'
is a task failure like any other; the job restarts from its last checkpoint
and meets the same record again, which is the point: a poison message is
visible, not silently lost. skip is the explicit choice to lose it.
Limitations¶
- A Kafka connector option only. Other string-channel connectors (Pulsar, RabbitMQ, NATS) take plain JSON.
- Message keys are not decoded or encoded: the source reads values, the upsert sink writes its JSON primary key as before.
- Avro decimals wider than 16 bytes (precision above 38) are refused; every timestamp logical type is delivered as epoch milliseconds, so sub-millisecond precision is not carried into SQL.
- Derived schemas are flat and fully nullable; there is no evolution beyond what the registry's own compatibility rules allow when a redeploy registers a changed column list. Use
schema_registry_auto_register='false'to write against a schema you own. - Protobuf
.prototext is parsed at runtime with the protobuf compiler library; schemas that depend on custom options or editions features the linked libprotobuf does not know will not parse. - The subject naming strategy is topic-name (
<topic>-value) or an explicit subject; record-name strategies are not implemented.
Testing¶
Unit suites, label schema_registry, with an in-process registry double
(impls/schema_registry/tests/fake_registry.hpp, on clink's own HTTP server):
the wire format, the client (caching, credentials, error codes), the
derivations, and each format checked against its reference implementation
(payloads produced by avro-cpp's generic API and by libprotobuf's dynamic
messages, then decoded by clink; clink's output decoded by them). The Kafka
suite (impls/kafka/tests/test_registry_formats.cpp) drives the string
factories end to end through librdkafka's mock broker, including the
decode_error policies and the build-time refusals. With Docker,
test_registry_formats_live.cpp runs the same round trip for all three
formats against a Redpanda broker with its built-in Schema Registry.
ctest --test-dir build -L schema_registry
ctest --test-dir build -L kafka