Eventstream exec: a real Kafka broker behind Fabric Spark options
Status: shipped, opt-in sidecar, graded Real. Eventstream item
management always works. Execution — a real Kafka topic the Fabric notebook
API can subscribe to — is real when an Apache Kafka KRaft broker is attached.
No broker → honest 501. --profile eventstream stays opt-in. Sail is the
default engine; the JVM overlay keeps a native spark-sql-kafka source.
Neither path maps Eventstream onto rate. Destinations and operators sit
on the Custom HTTP produce path: Lakehouse (Delta append), Reflex
(item job), Eventhouse (Kusto direct ingest), and Filter / GroupBy /
tumbling Window on the produce batch. Kafka stays the raw source; dests
see operator output.
This is the same move as Eventhouse (25-rti-kusto.md): terminate the Fabric names ourselves, relay bytes to a real engine.
Why Apache Kafka, not Redpanda
Section titled “Why Apache Kafka, not Redpanda”The family rule is no BSL surprise. Redpanda was Apache-2.0 and then moved to
the Business Source License; pinning it would put a license change under an
opt-in profile the way an unpinned latest tag puts a binary change under a
witness. apache/kafka is ASF, Apache License 2.0, KRaft (no ZooKeeper),
and multi-arch (linux/amd64 + linux/arm64), so it runs natively on
Apple silicon — unlike kustainer, which needs AVX2.
The image is digest-pinned in docker-compose.yml. Refresh with:
docker buildx imagetools inspect apache/kafka:3.9.1The two Fabric surfaces (one product)
Section titled “The two Fabric surfaces (one product)”Fabric Eventstream is not “the item or the notebook API”. Both exist:
- Item:
POST /v1/workspaces/{ws}/eventstreams— sources, streams, operators, destinations. This slice mints a DefaultStreamdatasourceIdand a Kafka topic{itemId}.{datasourceId}. - Notebook (notebook-with-event-stream):
df_raw = spark.readStream.format("kafka").options(**{ "eventstream.itemid": item_id, "eventstream.datasourceid": datasource_id,}).load()# schema: key, value, topic, partition, offset, timestampdf_raw.writeStream.foreachBatch(showDf).outputMode("append").start()Microsoft’s adapter uses the notebook Entra token to resolve those IDs to a
Kafka endpoint. User code has no kafka.bootstrap.servers. It is not
rate (timestamp / value). Mapping Eventstream options onto rate would
paint the engine matrix green with the wrong schema; that stays forbidden.
What the emulator does
Section titled “What the emulator does”notebook --format kafka + eventstream.*--> spark-agentspark-agent --GET /v1/eventstreams/{item}/sources/{ds}--> fabric-emulator JVM: rewrite to kafka.bootstrap.servers + subscribe → OSS kafka source Sail: GET …/sources/{ds}/events → LocalRelation (Kafka columns) foreachBatch runs in the agent (Sail cannot pickle UDFs)notebook --format kafka + bootstrap/subscribe/pattern/assign--> spark-agent JVM: spark-sql-kafka jar Sail: kafka-python consume → createDataFrame (bytes on Sail)notebook --write/writeStream format kafka--> spark-agent JVM: spark-sql-kafka jar Sail: kafka-python produce from collected rowsproducer / Custom HTTP --> KafkaPOST …/eventstreams --> mint datasourceId + CreateTopics- Broker sidecar —
apache/kafka:3.9.1,--profile eventstream. - Item create provisions a stream — DefaultStream id, topic name stored
on the item,
CreateTopicsagainst the broker when one is attached. Unknown IDs fail at resolve (404), not as an empty stream. - Custom source —
POST …/eventstreams/{id}/sources/{ds}/eventswrites JSONkey/valuebytes into that topic. Not thirty connectors. When a destination is bound, those same bytes (or the operator output) are drained after the produce succeeds. - Lakehouse destination — emulator-native
POST …/eventstreams/{id}/destinationswith{type, itemId, table}. Fabric’s topology JSON has no public REST (same as Reflex triggers).typeisLakehouse,Reflex, orEventhouse. - Reflex destination — same bind surface,
{type: Reflex, itemId}. A trigger on that Reflex witheventType: Microsoft.Fabric.Eventstream.EventReceivedandsource.itemIdthe Eventstream starts the action job per produced event (invokeType: EventTriggered,TriggerEvent.Key/.Value). - Eventhouse destination —
{type: Eventhouse, itemId, table}and optionaldatabase. After produce, operator output is ingested with.create-merge+.ingest inlineinto the isolated engine database for that KQL Database — the path kustainer actually supports. This is not Fabric’s streaming-ingest protocol and notKusto.Ingest. No engine attached → produce 502 naming--kql-url. - Operators — emulator-native
POST …/eventstreams/{id}/operators. Filter (eq/ne/gt/gte/lt/lte/contains/exists), GroupBy (count/sum/min/max/avg), and tumbling Window (this batch only; stamps_window_start). Join / Union / Expand and hopping/sliding are refused by name. Kafka consume is still unfiltered. - Spark adapter, both engines —
python/spark_agent/eventstream_kafka.py:- JVM: wraps classic
readStream.format("kafka").load()when both eventstream options are set: Entra-gated lookup, thenkafka.bootstrap.servers+subscribeon the real OSS Kafka source (spark-sql-kafka-0-10_2.12matching Spark 3.5.5). - Sail: the engine has no Kafka source and rejects
foreachBatchatstart(). The wrap consumes throughGET /v1/eventstreams/{item}/sources/{ds}/events, materialises a LocalRelation with the Kafka schema, and runsforeachBatchin the agent. One micro-batch, announced on stderr, no checkpoint — the same class of wrap as CDF / JSONmultiLine. Nativeformat("kafka")withkafka.bootstrap.serversplussubscribe/subscribePattern/assignis the same wrap without the Fabric IDs: driver consume → Kafka-schema LocalRelation on Sail. JSON offsets,includeHeaders, SASL PLAIN, GSSAPI, PEM SSL, and JKS/P12 truststores are honoured; a kafka sink produces from the driver. JVM native kafka still uses the jar.
- JVM: wraps classic
GET the item to read properties.streams[0].id — that is the
eventstream.datasourceid the notebook snippet needs.
Running it
Section titled “Running it”Default engine (Sail behind Livy):
docker compose --profile eventstream \ -f docker-compose.yml -f docker-compose.override.yml \ -f docker-compose.eventstream.yml upOr make up-eventstream. Two opt-ins, same grammar as RTI: the profile
starts the broker; the overlay sets FABRIC_KAFKA_BOOTSTRAP. Without the
overlay, create still returns an item and Spark resolution 501s.
Native Kafka source (checkpointed streaming, no LocalRelation) is the JVM
overlay: add -f docker-compose.spark-jvm.yml to that command, or
make up-jvm plus the eventstream profile and overlay.
The witness
Section titled “The witness”e2e/eventstream runs the same notebook snippet on both engines:
- JVM — CI job
eventstreamin.github/workflows/spark-jvm.yml(weekly/manual). Real OSS kafka source + realforeachBatch. - Sail — CI job
eventstream-sailin.github/workflows/ci.yml. Emulator consume + LocalRelation + localforeachBatch.
Both create an Eventstream item, bind a Lakehouse destination, produce JSON
records through the Custom HTTP source, assert the destination
Tables/<name>/_delta_log commit (row count and field names), then run
format("kafka") + eventstream options + foreachBatch and assert the Kafka
schema (key, value, topic, partition, offset) and row count. A
wrong item id must fail. rate must not appear.
Eventhouse destination and operators are Go-unit witnessed against the
in-process Kusto stand-in and Lakehouse dest tables. They are not in
e2e/eventstream — that job does not start --profile rti.
The one thing a stand-in cannot settle is whether the emitted KQL parses, and
the names in it are where that bites: a source field called kind, or a
destination bound with {"table": "kind"}, is a name real Kusto refuses bare
with SYN0002. The emitter quotes both (['kind']) rather than deciding which
names need it, and e2e/rti witnesses the emitted form against kustainer —
36 candidate keywords probed, the nine the engine actually refuses pinned as a
set, all 36 legal quoted, and a quoted name shown to resolve to the same entity
as the bare one, which is what makes quoting every drain’s table safe
(25-rti-kusto.md).
Boundaries (deliberate, not backlog)
Section titled “Boundaries (deliberate, not backlog)”- Lakehouse destination is this slice: Custom HTTP produce → Delta
append into
Tables/<name>. Bind is emulator-native REST. Spark-nativeformat("kafka")writes are not drained; there is no background consumer. - Reflex destination is this slice: Custom HTTP produce → real item
job via the existing Activator fire path. A
FileCreatedtrigger on the same Reflex does not fire. No dest bound → no job, even if a stream trigger exists. - Eventhouse destination is this slice: Custom HTTP produce → Kusto
direct ingest (
.create-merge+.ingest inline). Same produce trigger as Lakehouse; no background consumer. Fabric streaming ingest and queuedKusto.Ingeststay refused — the engine does not host them (25-rti-kusto.md). - Operators are this slice: Filter, GroupBy, tumbling Window on the produce batch. Destinations see the operator output; Kafka / DefaultStream stays the raw source. Join / Union / Expand and hopping/sliding windows stay refused — they need more than one stream or cross-batch state.
- Sail
format("kafka")is a driver consume/produce into a Kafka-schema LocalRelation (bytes on Sail).subscribe/subscribePattern/assign, JSONstartingOffsets/endingOffsets,includeHeaders, SASL PLAIN, GSSAPI (JAASkeyTab→KRB5_CLIENT_KTNAME), PEM SSL, and JKS/P12 truststores (converted to PEM for kafka-python) are honoured. A kafka sink (write/writeStream.format("kafka")) produces from the driver. Checkpointed streaming (isStreaming, resume from checkpoint) stays on the JVM overlay. - On Sail the result is materialised (
.explain()is a LocalRelation; one micro-batch;isStreamingis false). Checkpointed streaming is the JVM overlay. - Real-Time Hub explorer UX is not this slice.