Transactional downstream updates using Kafka
View as MarkdownWhen you load messages from Kafka into another data store, such as Snowflake or ClickHouse, the loader writes messages as they arrive. A query against the downstream store can therefore see some, but not all, of the changes Materialize made at a given timestamp. Use this pattern when downstream readers must only see complete, transactionally consistent results.
This guide walks you through the pattern end to end, using Snowflake as the destination:
- Create a Kafka sink in Materialize.
- Load the sink’s data topic and progress topic into Snowflake.
- Create a Snowflake view that exposes only committed changes.
The same approach works for any destination that stores Kafka headers and message keys alongside the message value.
How it works
Materialize writes each batch of sink messages to Kafka in a transaction, together with a progress record in the connection’s progress topic. The progress record says which timestamps the sink has fully committed. A downstream view can join the two topics and hide any row that is not yet covered by a progress record.
| Where | What to read | Value |
|---|---|---|
| Data topic | materialize-timestamp header |
The Materialize timestamp of the change, for example 1792700000000. |
| Data topic | materialize-sink-id header |
The sink’s ID, as shown in mz_sinks, for example u123. |
| Progress topic | Message key | mz-sink- followed by the sink’s ID, for example mz-sink-u123. |
| Progress topic | Message value | JSON of the form {"frontier":[1792700001000],"version":1}. |
The frontier is exclusive: the sink has committed every change with a
timestamp less than the frontier. To expose only committed data:
- Match each data message to the progress records whose key equals
'mz-sink-' || <materialize-sink-id header>. - Take the latest frontier for that sink.
- Keep the data messages where
materialize-timestampis less than that frontier.
Before you begin
- A Kafka or Redpanda cluster that Materialize can write to.
- A Kafka Connect cluster with the Snowflake connector for Kafka installed.
- A Snowflake database and schema, and a Snowflake user that the connector can authenticate as. See Installing and configuring the Kafka connector.
- A table, view, or materialized view in Materialize to sink. This guide uses a
materialized view named
orderswith a unique key columnid.
Step 1. Create a Kafka sink in Materialize
-
Create a connection to your Kafka cluster. You can optionally name the progress topic with the
PROGRESS TOPICoption; otherwise, Materialize uses a default name.CREATE SECRET kafka_password AS '<BROKER_PASSWORD>'; CREATE CONNECTION kafka_connection TO KAFKA ( BROKER '<BROKER_URL>', SASL MECHANISMS = 'SCRAM-SHA-256', SASL USERNAME = '<BROKER_USER>', SASL PASSWORD = SECRET kafka_password ); -
Create the sink. This guide uses the JSON format and the upsert envelope.
CREATE SINK orders_sink FROM orders INTO KAFKA CONNECTION kafka_connection (TOPIC 'orders') KEY (id) FORMAT JSON ENVELOPE UPSERT;Materialize adds the
materialize-timestampandmaterialize-sink-idheaders to every message automatically. You don’t need to set any options. -
Look up the sink’s ID and the name of its progress topic. You need the progress topic name to configure the Snowflake connector.
SELECT s.id AS sink_id, c.sink_progress_topic FROM mz_sinks AS s JOIN mz_kafka_connections AS c ON s.connection_id = c.id WHERE s.name = 'orders_sink';sink_id | sink_progress_topic ---------+-------------------------------------- u123 | _materialize-progress-<env>-<conn>
Step 2. Load both topics into Snowflake
Configure the Snowflake connector to load the data topic into a table named
raw_orders and the progress topic into a table named raw_progress. For
example:
{
"name": "materialize_orders",
"config": {
"connector.class": "com.snowflake.kafka.connector.SnowflakeSinkConnector",
"topics": "orders,<sink_progress_topic>",
"snowflake.topic2table.map": "orders:raw_orders,<sink_progress_topic>:raw_progress",
"snowflake.url.name": "<account>.snowflakecomputing.com:443",
"snowflake.user.name": "<user>",
"snowflake.private.key": "<private_key>",
"snowflake.database.name": "<database>",
"snowflake.schema.name": "<schema>",
"key.converter": "org.apache.kafka.connect.storage.StringConverter",
"value.converter": "com.snowflake.kafka.connector.records.SnowflakeJsonConverter",
"consumer.override.isolation.level": "read_committed"
}
}
Each Snowflake table gets two columns:
RECORD_CONTENT: the message value, as aVARIANT. For the progress topic, this is the JSON object containingfrontier.RECORD_METADATA: the message metadata, as aVARIANT. The message key is inRECORD_METADATA:keyand the headers are inRECORD_METADATA:headers.
Two settings in this configuration matter for the pattern:
key.convertermust beorg.apache.kafka.connect.storage.StringConverter. Otherwise, the connector does not keep the progress topic’s message key, and you can’t tell which sink a progress record belongs to.consumer.override.isolation.levelmust beread_committed, so that the connector never loads messages from aborted Kafka transactions. Your Kafka Connect worker must allow client configuration overrides (connector.client.config.override.policy). Alternatively, setconsumer.isolation.level=read_committedin the worker configuration.
Step 3. Create a view of committed changes in Snowflake
In Snowflake, create a view that keeps only the data messages that Materialize has fully committed:
CREATE VIEW committed_orders AS
WITH latest_progress AS (
-- The latest frontier for each sink that uses the progress topic.
SELECT
RECORD_METADATA:key::string AS progress_key,
MAX(RECORD_CONTENT:frontier[0]::number) AS frontier
FROM raw_progress
GROUP BY 1
)
SELECT
o.RECORD_METADATA:headers:"materialize-timestamp"::number AS mz_timestamp,
o.RECORD_CONTENT AS order_record
FROM raw_orders AS o
-- Match each data message to the progress records for its sink.
JOIN latest_progress AS p
ON p.progress_key =
'mz-sink-' || o.RECORD_METADATA:headers:"materialize-sink-id"::string
-- Keep only the changes below the frontier.
WHERE o.RECORD_METADATA:headers:"materialize-timestamp"::number < p.frontier;
The view works as follows:
latest_progressfinds the most recent frontier for each sink. Because the progress topic is shared by every sink on the same Kafka connection, the query groups by message key.- The join matches each order message to its sink’s frontier, using the
materialize-sink-idheader. - The
WHEREclause drops any message whose timestamp is not yet below the frontier. Those messages appear in the view once Materialize commits the rest of their timestamp.
Step 4. Query the view
Point downstream readers at committed_orders instead of raw_orders:
SELECT * FROM committed_orders ORDER BY mz_timestamp DESC LIMIT 10;
Every row in the result belongs to a Materialize timestamp whose changes have all been loaded into Snowflake.
Considerations
committed_orderscontains one row per change, not the current state oforders. With the upsert envelope, a deletion is a message with anullvalue (a tombstone). To build the current state, take the latest committed row for each key and handle tombstones as configured by the connector’sbehavior.on.null.valuessetting.- Materialize writes a progress record each time the sink’s frontier advances,
even when there is no new data. Expect
raw_progressto grow steadily. - Header keys starting with
materialize-are reserved for Materialize. - For other guarantees you need end to end, see End-to-end exactly-once processing.