In Part 1 and Part 2 of this series, you materialized a Kafka topic of stock trades as an Iceberg table and queried it with Spark. Spark is a good fit for exploring history. When an application needs real-time, high-concurrency serving, you often have to add a separate serving store. Lightning queries take a different approach. They serve results from a continuously updated copy of a Kafka topic, upstream of the data lake, with results that a stream processing engine aggregates as events arrive.
In this tutorial, you'll build a dashboard on that approach, with charts that refresh every few seconds. You'll:
A lightning query reads a durable, continuously updated copy of a Kafka topic, which Confluent refreshes within seconds of new data arriving. Like a snapshot query, it runs once and exits, but it targets low-latency, real-time responses and high concurrency. It supports point lookups, range scans, and full scans, which are the queries a dashboard issues.
The Real-Time Context Engine provides MCP tools that issue lightning queries on behalf of AI agents (see the Real-Time Context Engine tutorial). Here, you'll call the lightning query API directly.
Early Access. The lightning query API is an Early Access Program feature in Confluent Cloud, intended for evaluation in development and testing environments only. See the sign-up form to participate. RTCE is currently available on AWS only.
How this tutorial relates to Streamhouse: Streamhouse describes five stages: capture, transport, transform, govern, and serve. Here, trades in Kafka are the transport stage. The PTF is the transform stage, updating statistics incrementally as each trade arrives instead of recomputing them in a batch job. The registered schema on stats-by-ticker is the govern stage, and lightning queries are the serve stage.
The LastNTradesStats class (located under analytics-lightning-queries/last-n-trades-stats-ptf) emits an updated result on every incoming trade instead of waiting for a window to close. Rather than using a time-based window, it keeps the most recent N trades per ticker, so each emission describes the last N trades regardless of how long ago they happened. Note the following:
public void eval(
Context ctx,
@StateHint TradesState state,
@ArgumentHint(name = "input", value = SET_SEMANTIC_TABLE) Row input,
@ArgumentHint(name = "numTrades") Integer numTrades
) {
// ... read the trade's quantity, price, and side ...
addTrade(state, quantity.longValue(), price.doubleValue(), "SELL".equalsIgnoreCase(side));
// Drop the oldest trades until only the last N remain
boolean extremeEvicted = false;
while (state.trades.size() > numTrades) {
extremeEvicted |= evictOldest(state);
}
// Only rescan for min/max if an evicted trade could have been the min or the max
if (extremeEvicted) {
rescanMinMax(state);
}
emitStats(state);
}State size is bounded by N per ticker. Removing the oldest trade from the front of a list is O(N), which is fine for up to a few thousand trades per ticker. For much larger N, consider a ring buffer.
Check that the environment from Part 1 is active:
confluent environment listIf it isn't, set it as the active environment and select the cluster:
confluent environment use <ENVIRONMENT_ID>
confluent kafka cluster use <CLUSTER_ID>Get the cluster's cloud provider, region, and ID, which you'll need in later steps:
confluent kafka cluster describeCreate a Flink compute pool in the same cloud and region. The commands in this tutorial use aws and us-east-1. Substitute your cluster's region if it's different:
confluent flink compute-pool create analytics-pool \
--cloud aws \
--region us-east-1 \
--max-cfu 10Build an uberjar containing all dependencies:
./gradlew analytics-lightning-queries:last-n-trades-stats-ptf:shadowJarUpload the JAR as a Flink artifact:
confluent flink artifact create last_n_trades_stats_ptf \
--artifact-file ./analytics-lightning-queries/last-n-trades-stats-ptf/build/libs/last-n-trades-stats-ptf-all.jar \
--cloud aws \
--region us-east-1Note the returned artifact ID, which looks like cfa-123456. Open the Flink SQL shell:
confluent flink shell --compute-pool \
$(confluent flink compute-pool list -o json | jq -r '.[] | select(.name == "analytics-pool") | .id')Register the PTF as a function, replacing cfa-123456 with your artifact ID:
CREATE FUNCTION LastNTradesStats
AS 'io.confluent.developer.LastNTradesStats'
USING JAR 'confluent-artifact://cfa-123456';The Datagen connector registered a schema for stock-trades, so Flink already exposes it as a table. Inspect its columns:
DESCRIBE `stock-trades`;The STOCK_TRADES quickstart schema has side (BUY or SELL), quantity, symbol, price, account, and userid columns.
Create the output table, which Flink backs with a topic named stats-by-ticker. The primary key, upsert changelog mode, and compacted cleanup policy make RTCE keep only the latest record for each ticker.
RTCE's upsert requirements say that the Kafka record key must be raw bytes. Structured key schemas (Avro, Protobuf) aren't supported. When a table has a primary key, Flink defaults to the avro-registry key format, so set 'key.format' = 'raw' explicitly. A raw key must be a single column named key, so the ticker symbol goes in a column with that name.
CREATE TABLE `stats-by-ticker` (
`key` STRING,
total_volume BIGINT,
avg_price DOUBLE,
min_price DOUBLE,
max_price DOUBLE,
num_sell_orders BIGINT,
num_buy_orders BIGINT,
PRIMARY KEY (`key`) NOT ENFORCED
) WITH (
'changelog.mode' = 'upsert',
'kafka.cleanup-policy' = 'compact',
'key.format' = 'raw'
);Start the continuous statement that populates it. It partitions trades by symbol and computes statistics over each ticker's last 100 trades. To use a different window, change numTrades:
INSERT INTO `stats-by-ticker`
SELECT
symbol AS `key`,
totalVolume AS total_volume,
avgPrice AS avg_price,
minPrice AS min_price,
maxPrice AS max_price,
numSellOrders AS num_sell_orders,
numBuyOrders AS num_buy_orders
FROM LastNTradesStats(
input => TABLE `stock-trades` PARTITION BY symbol,
numTrades => 100
);This statement runs until you stop it. Open a second Flink SQL shell (or exit this one, because the statement keeps running in Confluent Cloud) and confirm that statistics are arriving:
SELECT * FROM `stats-by-ticker`;You should see one row per ticker, with values that change as trades arrive:
key total_volume avg_price min_price max_price num_sell_orders num_buy_orders
ZVZZT 104510 52.3 1.0 100.0 42 39
ZWZZT 98200 49.8 1.0 100.0 37 44Because the table has a primary key and is in upsert mode, the topic keeps only the latest statistics per ticker, no matter how many updates the PTF emits.
Exit the Flink SQL shell (exit;), then enable RTCE on the topic using your cluster's region:
confluent rtce rtce-topic create \
--cloud aws \
--region us-east-1 \
--topic-name stats-by-ticker \
--description "Live trading statistics per stock ticker over its last 100 trades: total_volume, avg_price, min_price, max_price, num_sell_orders, and num_buy_orders. One row per ticker. The key column holds the ticker symbol."Check the phase of the RTCE topic:
confluent rtce rtce-topic describe stats-by-tickerRe-run the command every few seconds until the Phase changes from PENDING (or PROVISIONING) to ACTIVE. Even then, the first queries can take a moment to succeed, as described in the next sections.
The lightning query API uses HTTP Basic authentication with a global-scope Confluent Cloud API key, the same credential that RTCE's MCP endpoint uses. Create one:
confluent api-key create --resource global --description "Lightning query tutorial"Save the API key and secret, then generate the Base64 token for the Authorization header and set the identifiers the API needs:
export TOKEN=$(printf '%s' '<API_KEY>:<API_SECRET>' | base64 | tr -d '\n')
export ENV_ID=$(confluent environment describe -o json | jq -r .id)
export CLUSTER_ID=$(confluent kafka cluster describe -o json | jq -r .id)A new API key can take up to a minute to start working. If your first request returns 401 unauthenticated, wait a few seconds and retry.
Your principal also needs the DeveloperRead role on the stats-by-ticker topic and on Schema Registry. If you created the environment and cluster yourself, you're an OrganizationAdmin and already have access.
Send a read-only SQL statement to the lightning query endpoint. Right after RTCE becomes ACTIVE, the table may still be loading, and requests can return errors like Table data is not yet available, The Lightning Table is temporarily unavailable, or Failed to obtain data plane token from token platform. These clear within a couple of minutes, so retry until you get rows back. First, list the tables that have RTCE enabled:
curl -s -X POST \
https://sql.us-east-1.aws.confluent.cloud/query/v1alpha1 \
-H "Content-Type: application/json" \
-H "Authorization: Basic $TOKEN" \
-d "{\"catalog_name\": \"$ENV_ID\", \"database_name\": \"$CLUSTER_ID\", \"query\": \"SHOW TABLES\"}" | jqNow query the five tickers with the highest total volume. The topic name contains a hyphen, so double-quote it in the query:
curl -s -X POST \
https://sql.us-east-1.aws.confluent.cloud/query/v1alpha1 \
-H "Content-Type: application/json" \
-H "Authorization: Basic $TOKEN" \
-d "{
\"catalog_name\": \"$ENV_ID\",
\"database_name\": \"$CLUSTER_ID\",
\"query\": \"SELECT key, total_volume FROM \\\"stats-by-ticker\\\" ORDER BY total_volume DESC LIMIT 5\"
}" | jqThe response contains the schema and rows inline. With the default JSON result format, every value is string-encoded:
{
"api_version": "query/v1alpha1",
"kind": "Statement",
"result": {
"result_format": "JSON",
"schema": {
"columns": [
{"name": "KEY", "type": {"type": "VARCHAR", "nullable": false}},
{"name": "TOTAL_VOLUME", "type": {"type": "BIGINT", "nullable": true}}
]
},
"data": [
["ZVZZT", "104510"],
["ZWZZT", "98200"]
]
}
}Run the same query a few seconds later and the numbers will have changed.
Lightning queries support point lookups (WHERE key = 'ZVZZT'), filters, ordering, and row limits. They don't support aggregates, joins, or subqueries, and they return at most 200 rows. That's why the PTF computes the statistics ahead of time: the query only has to read them. Lightning queries share a rate limit with the RTCE MCP tools. If you exceed it, the API returns a 429.
The dashboard directory contains a single-page web app built with Streamlit, a Python framework for data apps. It fits this tutorial for three reasons:
The app uses Plotly for the bar charts and the wordcloud package for the word clouds.
On each refresh, the app issues one lightning query per metric, ordered by that metric in descending order. For example:
SELECT key, total_volume FROM "stats-by-ticker" ORDER BY total_volume DESC LIMIT 10The Live tab shows a chart for each of the six statistics. The Historical tab computes the same statistics over every trade in the stock-trades Iceberg table from Part 2, using Spark SQL through the container's Thrift server:
SELECT symbol AS ticker, SUM(quantity) AS total_volume, AVG(price) AS avg_price,
MIN(price) AS min_price, MAX(price) AS max_price, ...
FROM `<CLUSTER_ID>`.`stock-trades`
GROUP BY symbolThe tabs complement each other. The live tab answers "what's happening right now" from the last 100 trades per ticker, with low latency. The historical tab answers questions over all history, in seconds, from the same data.
Create a virtual environment and install the dependencies:
cd analytics-lightning-queries/dashboard
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txtSet the connection details as environment variables. The app reads the API key and secret from the environment so they never land in source control:
export CONFLUENT_API_KEY=<API_KEY>
export CONFLUENT_API_SECRET=<API_SECRET>
export CONFLUENT_ENV_ID=$ENV_ID
export CONFLUENT_CLUSTER_ID=$CLUSTER_ID
export CONFLUENT_REGION=us-east-1If the Spark container isn't running on localhost:10000, also set SPARK_THRIFT_HOST and SPARK_THRIFT_PORT. The historical tab needs the container from Part 2. The live tab works without it.
Start the app:
streamlit run app.pyStreamlit opens http://localhost:8501 in your browser. The sidebar lets you:
Watch the live charts update as new trades flow through the PTF. On the historical tab, click Re-run query to recompute from the latest data (results are cached for 5 minutes), or expand Run your own Spark SQL to query the Iceberg table yourself.
In Part 2, the Spark notebook returned a handful of rows from SELECT *. That was enough to confirm that Tableflow was keeping the Iceberg table current, but it only sampled the table. The historical tab shows what Spark is for: aggregating over every trade ever written to the table. The dashboard's Spark query groups the full stock-trades table by ticker and computes total volume, average, minimum, and maximum price, and buy and sell counts. It scans the entire table, which grows with each trade, and returns one row per ticker. The dashboard sends the query to the Spark container from Part 2 over its Thrift server and charts the result next to the live data.
Both tabs are derived from the same stream of trades, but they answer different questions and are served by different systems:
| Live tab | Historical tab | |
|---|---|---|
| Question | What's happening right now? | What has happened over all time? |
| Scope | Last 100 trades per ticker | Every trade in the Iceberg table |
| Computed by | Flink PTF, incrementally, as each trade arrives | Spark, on demand, by scanning the table |
| Served from | Lightning queries on the stats-by-ticker topic | Iceberg table maintained by Tableflow |
| Latency | Low latency, suitable for frequent refresh and many concurrent users | Seconds, so the dashboard caches results for 5 minutes |
Pairing the two avoids forcing one system to do both jobs. Spark is well suited to scanning large volumes of history but is a poor fit for serving many frequent queries. Lightning queries serve fresh, precomputed results quickly but don't hold the full history. Because both read from the same Kafka topic of trades, there's no export job or second database to keep in sync, and the live and historical views stay consistent with each other.
Stop the dashboard with Ctrl+C. Stop the Spark container from Part 2 by running the following from the top-level tutorials directory:
docker compose -f analytics-query-iceberg/docker-compose.yml downWhen you're finished with the whole series, delete the Confluent Cloud environment created in Part 1. First get the environment ID of the form env-123456:
confluent environment listDelete the environment, which also deletes the cluster, connector, Tableflow topic, Flink compute pool, RTCE topic, and everything else inside it:
confluent environment delete <ENVIRONMENT_ID>API keys aren't tied to an environment, so deleting the environment doesn't delete the ones you created for this series. List your API keys:
confluent api-key list --current-user -o jsonThen delete the global API key from this tutorial (description: Lightning query tutorial) and the Tableflow API key from Part 2 (resource: tableflow):
confluent api-key delete <GLOBAL_API_KEY>
confluent api-key delete <TABLEFLOW_API_KEY>