From 17e4ff07abd8a462999c23e8b3af7a7ea0e49f36 Mon Sep 17 00:00:00 2001 From: Olav Gjerde Date: Sun, 23 Aug 2026 11:13:31 +0200 Subject: [PATCH] docs: add FABRIC.md, a design proposal for a Microsoft Fabric integration Records the business case, user stories, the Fabric concepts a reviewer needs, what OneLake is and how it compares to other lakehouse systems, and a four-phase design: an exporter that lands change files in OneLake, an Eventstream bridge with write-back, a Fabric workload, and a Power Query connector. Each phase has a diagram. Nothing is implemented yet. Co-Authored-By: Claude Fable 5 Signed-off-by: Olav Gjerde --- FABRIC.md | 857 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 857 insertions(+) create mode 100644 FABRIC.md diff --git a/FABRIC.md b/FABRIC.md new file mode 100644 index 00000000..3aaa59f6 --- /dev/null +++ b/FABRIC.md @@ -0,0 +1,857 @@ +# Microsoft Fabric integration + +A proposal for making DataHub a data source for Microsoft Fabric, so that everything a customer +curates in DataHub (assets, timeseries, events, the graph between them) shows up in Power BI and +the rest of Fabric without anyone writing an import script. Nothing in this document is built +yet. It describes what we would build, in four phases, so the work can be split into pull +requests. + +> **Status: proposal.** The Fabric details were checked against the Microsoft Learn +> documentation in August 2026 (see [Sources](#sources)). The DataHub details were read from the +> code, and the relevant files are named. The [open questions](#open-questions) need answers +> before phase 1 starts. + +## Why + +### The business case + +In the markets we sell into, Microsoft is the default. The industrial companies, utilities and +public-sector organisations we talk to run on Microsoft 365, sign in with Entra ID, report in +Power BI, and increasingly have a Fabric capacity because it came with the Power BI licence +they already pay for. Their IT departments have approved Microsoft; anything else has to earn +its place. That shapes how DataHub gets bought: + +- **The buyer's question is "how does this fit with what we have".** A product that feeds + Power BI and Fabric is an addition to an approved platform. A product that asks people to + open a new tool for reports is a replacement, and replacements lose. Fabric support turns + DataHub from the second kind into the first. +- **We do not compete with Fabric, we complete it.** Fabric is an analytics platform; it has no + opinion about what a pump, a well or a substation is, how tags relate to equipment, or what + counts as a valid reading. DataHub is that layer. "DataHub curates the industrial data, Fabric + analyses it" is a story a Microsoft-first buyer accepts, and it answers the objection we hear + most often: "we already have Fabric, why do we need you". +- **Once reports depend on it, DataHub is in the daily workflow.** A Power BI report that + management looks at every Monday is a stronger reason to keep paying than an API nobody sees. + The mirrored tables make DataHub the source those reports are built on. +- **It is a distribution channel.** Fabric's Workload Hub and the published list of Open + Mirroring partners put DataHub in front of every Fabric customer's admin, which is a kind of + reach we cannot buy. Microsoft's partner programmes are built around exactly this. +- **The data stays where the customer put it.** DataHub remains open source and self-hosted; + Fabric gets a copy of whatever datasets the customer chooses to mirror. That matches the data + residency and sovereignty requirements we already sell on, rather than conflicting with them. + +### Why the return is good + +The cost side is small for what it buys: + +- **Phase 1 is one backend service and a handful of small changes.** No new dependencies, no + new infrastructure, no user interface. It reuses Vault, Pulsar, Postgres and ClickHouse + exactly as they are used today. +- **One integration reaches the whole Fabric user base.** Power BI, Spark notebooks, the SQL + endpoint, Digital Twin Builder and Copilot all read the same mirrored tables. We do not build + a connector per tool. +- **Microsoft maintains the hard parts.** Open Mirroring takes our change files and maintains + the Delta tables, the SQL endpoint and the Power BI model; the mirrored storage is free up to + a generous limit. We write files; Fabric does the rest. +- **The tables are open.** Because OneLake exposes them as Delta and Iceberg, the same export + serves customers on other lakehouse platforms without separate work. +- **It de-risks the sale more than it costs.** A prospect who can see DataHub data in Power BI + on day one of a pilot has something to show their management. That shortens pilots and + removes the "we will have to build the reporting ourselves" line item from their side. + +The phases after the first (live data, sign-in with a Microsoft account, the Fabric workload) +build on the same foundation and can each be justified by a customer asking for them. + +### The gap today + +Today the only way to get DataHub data into Fabric is to write your own client against the REST +API and page the JSON into a notebook. That has four problems: + +- The assets, timeseries and events in DataHub are invisible to the Power BI and Copilot users + in the organisation. +- Every customer builds the same fragile import script, and none of them handle updates and + deletes correctly. +- There is no way to get live data (alerts, real-time dashboards). +- DataHub is not listed in Fabric's Workload Hub, so organisations that start from Fabric never + find it. + +Fabric now has stable, documented ways for an outside product to plug in. This is an engineering +task, not research. + +## User stories + +The people involved, and what each of them needs. Each story names the phase that delivers it, +so the phases below can be read as "which of these does this step satisfy". Acceptance notes +say what "done" looks like. + +### Customer side + +**Power BI analyst** + +1. *As an analyst, I want the DataHub assets, timeseries and events for my plant to show up as + tables in Fabric, so that I can build Power BI reports on them without asking a developer.* + Phase 1. Done when the six tables appear in a mirrored database and a report built on them in + Direct Lake mode refreshes without any manual step. +2. *As an analyst, I want the tables to stay current, so that my reports show what DataHub shows.* + Phase 1. Done when a value written through the DataHub API is visible in the mirrored table + within minutes, and a deleted resource disappears from it. +3. *As an analyst, I want to join datapoints to their timeseries and assets, so that I can + group and filter by plant, unit or tag.* Phase 1. Done when `datapoints.timeseries_id`, + `timeseries.data_set_id` and `relationships` carry the ids needed for those joins. +4. *As an analyst, I want to pull aggregated datapoints from DataHub into Power BI Desktop + directly, so that I can explore without waiting for a mirror to be set up.* Phase 4. + +**Data engineer or data scientist** + +5. *As a data engineer, I want to read DataHub data from a Spark notebook in Fabric, so that I + can combine it with the rest of our lakehouse.* Phase 1. Done when the mirrored tables are + readable from a notebook and from the SQL endpoint. +6. *As a data engineer, I want to read the same tables from tools outside Fabric, so that we are + not locked in.* Phase 1, free with OneLake's Iceberg metadata. Done when an Iceberg or Delta + reader outside Fabric opens a mirrored table. +7. *As a data scientist, I want to write results (a computed timeseries, a detected event) back + into DataHub from a notebook, so that they show up in the console and the graph.* Phase 2. + Done when a notebook can get a DataHub token with a Microsoft identity and call the ingest + endpoints. +8. *As a data engineer, I want to map DataHub's asset graph into Digital Twin Builder, so that + the twin uses the ontology we already maintain in DataHub.* Phase 1 (documentation). Done + when the mapping guide takes someone from the mirrored tables to a working twin. + +**Operations engineer** + +9. *As an operations engineer, I want live datapoints and events in a real-time dashboard and + in alerts, so that I can act on them as they happen rather than after the next refresh.* + Phase 2. Done when an eventstream receives DataHub datapoints and an Activator rule on it + fires. + +**Fabric administrator** + +10. *As a Fabric admin, I want to set the integration up with one app registration and one + workspace permission, so that it follows the same security model as everything else in our + tenant.* Phase 1. Done when the operator guide needs nothing from the customer beyond the + app registration, the workspace role and the two tenant settings. +11. *As a Fabric admin, I want the DataHub data to respect workspace permissions, so that only + the right people see it.* Phase 1. Done when access to the mirrored database is governed + purely by Fabric workspace and item permissions. +12. *As a Fabric admin, I want to find DataHub in the Workload Hub and enable it for a workspace, + so that I do not have to install or host anything.* Phase 3. +13. *As a Fabric admin, I want to sign in to the DataHub console with my Microsoft account, so + that there is one identity to manage.* Phase 2. + +**Fabric user of the DataHub workload** + +14. *As a Fabric user, I want to choose which DataHub datasets are mirrored into my workspace + and see whether the mirror is healthy, from inside Fabric, so that I do not need access to + DataHub's own tools.* Phase 3. Done when the workload item lets a user pick a tenant and + datasets, create or attach the mirrored database, and see lag and last error. + +### DataHub side + +**DataHub operator (whoever runs the platform for a customer)** + +15. *As an operator, I want to enable Fabric export for one tenant by adding a block to its + Vault entry, so that it follows the way every other per-tenant setting works.* Phase 1. + Done when adding the `fabric` block is the only configuration step. +16. *As an operator, I want to be sure that a Fabric outage, an expired secret or a + misconfigured tenant cannot slow down or stop ingest for any tenant, so that the integration + cannot take the platform down.* Phase 1. Done when stopping the writer for a tenant leaves + API ingest for all tenants unaffected, with only that tenant's export lagging. +17. *As an operator, I want to see per-tenant export status (lag, last file, last error) from + the console or an endpoint, so that I can answer "is the mirror up to date" without reading + logs.* Phase 1. +18. *As an operator, I want to rebuild a table, a timeseries or a year of datapoints on demand, + so that I can fix drift without a full re-export.* Phase 1. +19. *As an operator, I want the exporter to run on the same hosts, with the same Vault, systemd + and container packaging as the other services, so that there is nothing new to operate.* + Phase 1. + +**DataHub developer** + +20. *As a developer, I want the exporter to read from Postgres and ClickHouse directly rather + than through the REST API, so that a full load of a large tenant finishes in minutes, not + hours.* Phase 1. +21. *As a developer, I want no Azure SDK and no Parquet library in the build, so that the + dependency footprint stays small and the build stays fast.* Phase 1. +22. *As a developer, I want Postgres to remain the single source of truth, so that the mirror + can always be rebuilt from it and never has to be migrated or repaired on its own.* Phase 1. + +**IntelliStream (the product)** + +23. *As the product owner, I want Fabric export to be a feature that can be switched on per + tenant, so that it can be sold as an add-on or bundled later without a code change.* + Phase 1. +24. *As the product owner, I want DataHub listed in the Fabric Workload Hub, so that Fabric + customers discover it without us calling them first.* Phase 3. + +### Stories we are deliberately not taking on + +- Using OneLake as the file store for DataHub's own files. +- Running DataHub's own queries (console, API, analysis) against Fabric instead of ClickHouse + and Neo4j. +- Propagating datapoint deletes exactly in every case. Deletes of a time window are propagated; + deleting a whole timeseries leaves its points in the mirror until the next scheduled rebuild, + and consumers are expected to join against `timeseries`. + +## Fabric concepts you need for this document + +None of us has used Fabric. This section is the minimum vocabulary for reviewing the plan, +written for people who work on Linux and open-source software. Skip what you already know. + +### The product + +- **Microsoft Fabric** is a software-as-a-service analytics platform. It bundles storage + (OneLake), several query engines, data pipelines, real-time streaming and Power BI into one web + portal at `app.fabric.microsoft.com`. Nothing runs on the customer's machines; there is nothing + to install. It is sold separately from Azure, although it runs on Azure and uses Azure's + identity service. +- **Capacity.** The compute you pay for. A capacity is a pool of "capacity units" (CU) bought as + a size, `F2` up to `F2048`. Every workspace is attached to one capacity, and everything that + runs in the workspace (queries, Spark jobs, Power BI refreshes, mirroring reads) draws from it. + A paused capacity stops everything in its workspaces, including our mirroring. A free 60-day + trial capacity (64 CU) exists for evaluation. +- **Tenant.** One per organisation, tied to the organisation's Microsoft Entra ID directory. It + holds the single OneLake and all workspaces. Tenant-wide switches live in the **admin portal**; + two of them matter to us (*Service principals can use Fabric APIs*, and *Users can access data + stored in OneLake with apps external to Fabric*). A customer's Fabric admin has to flip them. +- **Workspace.** A folder with its own members and permissions (Admin, Member, Contributor, + Viewer), attached to one capacity. Everything a user creates lives in a workspace. Our + mirrored database is one item in a workspace the customer chooses. +- **Item.** Anything in a workspace: a lakehouse, a warehouse, a mirrored database, a notebook, + a report. Items have a type, an id, and a folder in OneLake. The Extensibility Toolkit lets a + partner define new item types, which is how a "DataHub" item would appear next to the built-in + ones. + +### Storage and tables + +- **OneLake.** Fabric's one storage account per tenant, explained in the next section. +- **Parquet.** The column-oriented file format everything here is built on. One file holds + rows for a set of columns, compressed per column. Open, widely supported, including by + ClickHouse. +- **Delta Lake table** (usually just "Delta table"). A folder of Parquet files plus a + `_delta_log` folder with a JSON transaction log. The log says which files make up the current + version, so writers can add, replace and delete files atomically and readers can time-travel. + Fabric's native table format. Apache Iceberg is the competing open standard for the same idea. +- **Lakehouse.** A Fabric item that is a folder in OneLake with a `Tables` area (Delta tables) + and a `Files` area (anything). Spark notebooks work against lakehouses. Digital Twin Builder + reads from one. +- **Warehouse.** A Fabric item that feels like a SQL Server database (T-SQL, transactions) but + stores its tables as Delta in OneLake. +- **Mirrored database.** A Fabric item that holds a continuously updated copy of an external + database as Delta tables. Open Mirroring is the variant where we, not Microsoft, write the + change files. Our exporter creates and feeds one of these. +- **Landing zone.** The `Files/LandingZone` folder inside a mirrored database where the change + files go. Fabric's replicator watches it and folds the files into the Delta tables. +- **SQL analytics endpoint.** A read-only T-SQL endpoint that every lakehouse and mirrored + database gets automatically. It speaks the SQL Server protocol (TDS), so `sqlcmd` and JDBC + work, but plain HTTP does not. +- **Shortcut.** A symlink inside OneLake to data that lives somewhere else (another workspace, + S3, ADLS). Not useful to us because DataHub does not store files in an object store. + +### Reporting and real time + +- **Power BI.** Microsoft's reporting tool, now part of Fabric. Reports are built on a + **semantic model** (formerly "dataset"): a set of tables, relationships and measures. A + semantic model can copy data in (import mode), query the source per click (DirectQuery), or + read Delta tables in OneLake directly (**Direct Lake**, the fast path). Direct Lake only works + on Delta tables, which is why we want one `datapoints` table and not seven. +- **Real-Time Intelligence.** Fabric's streaming side. An **Eventstream** is a managed Event + Hubs (Azure's Kafka-like message service) with a visual pipeline. A **custom endpoint** on an + eventstream gives you a connection string to push into it using the Event Hubs, AMQP or Kafka + protocol. An **Eventhouse** holds **KQL databases**, a time-series store queried with the Kusto + Query Language; eventstreams usually end there. **Activator** watches a stream and triggers + alerts or actions when a condition holds. +- **Digital Twin Builder.** A Fabric item where the customer defines an ontology (entity types, + properties, relationships) and binds it to lakehouse tables, including time series. Still in + preview. +- **Dataflow Gen2** and **pipelines.** Fabric's low-code ETL (Power Query based) and + orchestration (Data Factory based). Relevant only for the Power Query connector in phase 4. +- **Notebook.** A Spark notebook (PySpark, Scala, SQL, R) running on Fabric's Spark. This is + where a customer would call our REST API from inside Fabric. + +### Identity + +- **Microsoft Entra ID** is the new name for Azure Active Directory: the identity provider + for every Microsoft cloud tenant. Users, groups and applications live there. Fabric has no + users of its own; every Fabric login is an Entra login. +- **App registration** is how you create an OAuth2 client in Entra. It has an application + (client) id and a client secret or certificate. Registering one creates a **service + principal**, the identity that the app acts as in a given tenant. Our exporter authenticates + as a service principal the customer registers. Note that the service principal's *object id* + and the *application id* are different values; `EntraID.md` has been bitten by that. +- **Scopes** such as `https://storage.azure.com/.default` name the API a token is for. A token + for OneLake is not valid for the Fabric REST API and vice versa; the exporter needs both. +- **On-behalf-of** and **token exchange.** Flows where a service turns a user's token into a + token for another API. The Keycloak side of that is the `jwt-bearer` grant in `EntraID.md`. + +### Extending Fabric + +- **Workload.** Fabric's word for a product area (Data Engineering, Data Warehouse, Power BI). + The **Extensibility Toolkit** lets a partner add one: a web app that Fabric loads in an iframe, + with a manifest that declares the item types it provides. The partner hosts the app; Fabric + supplies Entra tokens and a host API for navigation and notifications. The **Workload Hub** is + the catalogue where customers find and enable workloads. The old name for the toolkit is the + Workload Development Kit; the docs still use both. +- **Fabric REST API.** `https://api.fabric.microsoft.com`, for creating and managing + workspaces and items, including mirrored databases. Authenticated with an Entra token. +- **OneLake API.** The ADLS Gen2 REST API at `https://onelake.dfs.fabric.microsoft.com`, for + reading and writing files. Also authenticated with an Entra token, but for a different scope. + +### What works from Linux + +- Everything in phases 1 and 2 is REST over HTTPS: Entra token endpoint, OneLake, Fabric REST + API, Event Hubs. No Microsoft SDK is required, and the exporter is a normal Java service. +- The Azure CLI (`az`) and the Fabric CLI (`fab`, `pip install ms-fabric-cli`) both run on + Linux and are enough to script app registrations, workspaces and items for testing. +- The Extensibility Toolkit starter kit is Node.js and TypeScript and supports Linux for local + development; it also needs PowerShell 7 (`pwsh`) and the .NET SDK for its dev gateway. +- The Power Query SDK and the on-premises data gateway are Windows-only, and Power BI Desktop + is a Windows application. Phase 4 cannot be developed or tested on Linux. That is one more + reason it is last. +- To try anything for real we need a Fabric tenant with a capacity. The free trial gives 60 + days and 64 CU; after that the smallest paid capacity (`F2`) is enough for development. + +## What OneLake is + +OneLake is the storage layer of Microsoft Fabric. Every Fabric tenant gets exactly one; it is +created automatically, cannot be deleted, and there is no infrastructure to manage. Think of it +as one big, organisation-wide folder tree in the cloud that every Fabric tool reads from and +writes to. Since our exporter writes into it and everything downstream reads from it, it is +worth understanding on its own. + +- **It is Azure Data Lake Storage underneath.** Any tool that speaks the ADLS Gen2 API can read + and write it. A Fabric workspace appears as a container, and each item in the workspace + (a lakehouse, a warehouse, a mirrored database) as a folder inside it. That API is what our + exporter uses. +- **Tables are stored in an open format.** A table is a folder of Parquet files plus a + transaction log. The native format is Delta Lake; Apache Iceberg is also supported, and OneLake + generates the metadata of the other format automatically, so a Delta table can be read by an + Iceberg client and an Iceberg table by every Fabric engine. +- **One copy, many engines.** Spark notebooks, the T-SQL warehouse, the KQL engine and Power BI + (in Direct Lake mode) all read the same files. Nothing is copied between them. +- **Shortcuts** are references to data that lives somewhere else (another workspace, an S3 or + ADLS bucket, an Iceberg table in another system). The data stays where it is and shows up in + OneLake as if it were local. +- **Mirroring** is the opposite: a managed, continuous copy of an external database into + OneLake as Delta tables. Open Mirroring is the variant where we write the change files + ourselves. Mirrored storage is free up to a limit tied to the capacity size (one terabyte per + capacity unit), and the replication itself does not consume capacity. Querying it does. +- **Security** is defined once, per folder, table, row or column, and enforced by every engine. + +### OneLake compared to other systems + +These are not all the same kind of thing, which is the main source of confusion. A table format +is a specification; a data lake is storage; a warehouse or an OLAP database is storage plus a +query engine. + +| | What it is | Who owns the storage | Table or file format | Query engine | Relation to DataHub | +|---|---|---|---|---|---| +| **OneLake** | A managed data lake, one per Fabric tenant | Microsoft, inside the Fabric tenant | Delta Lake (native) and Iceberg, both on Parquet | None of its own; Fabric's engines (Spark, T-SQL, KQL, Power BI) read it | Where our exporter lands the data. A derived copy, never the source of truth. | +| **Apache Iceberg** | An open table format: a spec for how Parquet files and metadata make up a table with ACID commits, schema evolution and time travel | Whoever runs the object store (S3, ADLS, GCS) | Iceberg on Parquet (also ORC, Avro) | None; Spark, Trino, Flink, Snowflake, ClickHouse and others implement readers and writers | Not used directly. Because OneLake virtualises Delta tables as Iceberg, the tables we mirror can also be read by Iceberg clients outside Fabric. | +| **Snowflake** | A cloud data warehouse | Snowflake, in its own managed storage, or the customer's bucket for Iceberg tables | Proprietary micro-partitions, or Iceberg for external tables | Snowflake's own SQL engine | A possible second consumer of the mirrored tables through OneLake's Iceberg view. Fabric can also mirror Snowflake into OneLake. Not something we export to directly. | +| **Databricks** | A lakehouse platform: Spark compute plus a catalog (Unity Catalog) over the customer's object storage | The customer's own S3, ADLS or GCS bucket | Delta Lake (its own format), reads Iceberg | Spark and Databricks SQL | Same position as Snowflake: can read the mirrored Delta tables through a shortcut or Unity Catalog, and Fabric can mirror Unity Catalog metadata into OneLake. | +| **ClickHouse** | An OLAP database: a column-oriented engine with its own storage format, built for fast aggregation over very large tables | The server's local disks (or object storage it manages) | MergeTree, proprietary; can read and write Parquet, and can read Iceberg and Delta tables | ClickHouse's own SQL engine | DataHub's hot store for datapoints and events. Stays that way. It also produces the CSV and Parquet files the exporter uploads. | + +What this means for the design: + +- ClickHouse and Postgres remain the systems of record. OneLake gets a copy for the Fabric user + base, the same way the WebSocket fan-out gives other consumers a live feed. +- We write plain files in an open format. We do not need a Delta or Iceberg library: Fabric + builds the Delta table and the Iceberg metadata from the files we upload. +- Once the tables are in OneLake, they are not locked to Fabric. Snowflake, Databricks, Trino or + DuckDB can read them through the Iceberg or Delta metadata, so the exporter is also a path to + those platforms without separate connectors. + +## How an outside product can plug into Fabric + +Fabric has several integration points. This table lists the ones that matter for DataHub. + +| Fabric feature | What it does | What it means for DataHub | +|---|---|---| +| **Open Mirroring** (generally available) | We upload change files (Parquet or CSV) to a folder in OneLake, Fabric's storage. Fabric turns them into queryable tables, a SQL endpoint and a Power BI model. | The main path. Full load plus incremental updates and deletes, no work on the customer side, and the storage is free. | +| **Eventstream custom endpoint** | An endpoint that speaks the Event Hubs, AMQP or Kafka protocol. Whatever we send shows up in Fabric's real-time tools: KQL databases, alerts (Activator), live dashboards. | Live datapoints and events. | +| **Digital Twin Builder** (preview) | Lets the customer define entity types and relationships and attach time series to them, using tables from a lakehouse. | Reads the tables we mirror. DataHub's asset graph becomes the source for the twin. | +| **Extensibility Toolkit** (replaces the Workload Development Kit) | Lets a partner host a web app that appears inside the Fabric portal, with Microsoft sign-in. The partner can publish it to a few customers for preview, then to Fabric's Workload Hub. | This is the "plugin": a DataHub page inside Fabric to set up and monitor the integration. Also how customers discover DataHub. | +| **Power Query SDK connector** | A custom connector (`.mez` file) for Power BI Desktop. In Fabric's Dataflow Gen2 it only works through the on-premises data gateway. | Lets an analyst pull from the live API without a Fabric capacity. Low priority once mirroring exists. | +| OneLake shortcuts | Lets Fabric read an external S3 or ADLS bucket without copying it. | Not usable. DataHub stores files on a local filesystem, not in an S3-compatible store. | + +In short: the "plugin" people ask for is a Fabric workload, but a workload is only a user +interface. The data has to arrive in OneLake first, and that is a job for a new DataHub service. +So the order is: Open Mirroring first, Eventstream second, the workload last. + +## What we have today + +Things in the current code that shape the design. File paths are given so you can check. + +- **The REST API returns JSON in pages.** Good for clients, bad for bulk export. The new service + should read the databases directly instead of going through the API. +- **Datapoints and events are in ClickHouse** (`datahub-api/src/main/resources/db/clickhouse.sql`). + ClickHouse can output Parquet and CSV itself and stream it through the existing client + (`ClickHouseClientPool`). We do not need a Parquet library in Java. +- **The Pulsar ingest topics are shared by all tenants.** There is one `all-datapoints` topic + (16 partitions, messages not keyed by tenant), one `resources/cud-events` topic (not + partitioned, keyed by tenant) and one `events/cud-events` topic (16 partitions, keyed by + tenant). See `datahub-commons/.../pulsar/TopicNames.java` and + `datahub-api/.../messaging/PulsarProducerConfig.java`. The only per-customer topic is + `subscriptions/fanout`. +- **A slow consumer on those topics breaks ingest for everyone.** The topics have a backlog + limit, and when it is reached the API can no longer publish (`datahub-api/PULSAR_SETUP.md`). + Pulsar measures the backlog from the slowest consumer. So if our exporter falls behind because + Fabric is down, every tenant's ingest stops. The exporter must never be a slow consumer on + these topics. +- **Pulsar Failover is not "one active, one standby" on a partitioned topic.** It splits the + partitions between the connected consumers. Only the non-partitioned resources topic behaves + as active/standby. `InstanceLock` (`datahub-infra/.../config/InstanceLock.java`) only stops + two instances with the same id, not two instances in general. +- **Change messages are notifications, not complete rows.** A timeseries update message carries + only the changed fields (`TimeseriesService`). A delete message carries what the caller sent, + not the resolved ids, and not the relationships that were deleted along with the node + (`ResourceService.delete`). Deletes are physical. The `lastUpdated` column is set by Hibernate + and does not cover every write path. +- **Datapoint messages do not say which dataset the timeseries belongs to.** They carry the + timeseries id, value type and points (`DataCollectionBin`). +- **Per-tenant settings come from Vault.** `TenantConfigService` reads the `tenant-resources` + secret and raises `TenantAddedEvent` and `TenantRemovedEvent`. It does not raise anything when + an existing tenant's settings change. `TenantFeatures` (`datahub-api-model`) already has an + optional add-on flag (`chat`, off by default) that we can copy. +- **Database migrations** are in `datahub-infra/src/main/resources/db/migration/`. Only + `datahub-api` and `datahub-cleanup` run them. +- **Microsoft sign-in is half done.** The Java SDK can exchange a Microsoft token for a DataHub + token (`TokenProvider`). The Keycloak side is written up in [EntraID.md](EntraID.md) but has + never been tested, and it predates Keycloak Organizations. +- **Nothing pushes data out of DataHub today.** Retry, checkpointing and backpressure must be + written. The closest existing code is the console's LLM client (`ChatConfig`: a secret in Vault + behind a feature flag) and the Vault client in `TenantConfigService`. +- **The OpenAPI document** is generated when the API runs (`/api-docs`). No copy is committed. + +## Design + +### The new service + +A new optional service, **`datahub-fabric-connector`**. It is a headless Spring Boot +application like `datahub-cleanup`: scheduled work, one instance, depends on `datahub-infra` and +`datahub-commons`, settings from Vault. It talks to Microsoft with the JDK `HttpClient` only, no +Azure SDK. It does not run database migrations; if a tenant's schema is not migrated yet, it +skips that tenant until the next run. + +It has two parts, so that a Fabric outage can never stop DataHub ingest: + +1. **Forwarder.** A small consumer on the three shared ingest topics. It looks at a `tenantId` + message property (we add that at the producers), ignores tenants that have no Fabric + settings, and copies the rest to a new per-customer topic `fabric/export` in that customer's + Pulsar tenant. It acknowledges right away and never waits on Fabric. The new topic is set up + like the subscription fan-out topic (`SubscriptionTopicProvisioner`), but with a retention + window and backlog eviction, so an outage only loses that one tenant's data, and only after + the retention window. +2. **Writer.** One writer per tenant reads `fabric/export`, collects messages into files (bounded + by time and size), uploads them to OneLake, then acknowledges. Open Mirroring needs exactly one + writer per table folder, so the writer takes an exclusive lock per tenant: an exclusive Pulsar + producer (`ProducerAccessMode.Exclusive`) on a small lock topic. A second instance fails to + start rather than writing in parallel. Locking per tenant, not globally, means tenants can be + spread over several instances later. + +The Eventstream bridge in phase 2 is a second consumer on `fabric/export` and gets the same +protection for free. + +### Settings + +Each tenant gets a `fabric` block in its Vault `tenant-resources` entry. The `fabric` feature +flag in `TenantFeatures` is true when the block exists, so there is one place to configure it. + +```json +"fabric": { + "entra-tenant-id": "...", + "client-id": "...", + "client-secret": "...", + "workspace-id": "...", + "mirrored-database-id": "...", + "datasets": ["plant-a", "plant-b"], + "eventstream": { "connection-string": "Endpoint=sb://..." } +} +``` + +- `datasets` lists which datasets to export, by external id. Child datasets are included, the + same way access grants work (`DataSetRepository.findDatasetClosure`). `"*"` means all. +- `mirrored-database-id` is optional. If it is missing, the connector creates the mirrored + database through the Fabric REST API. +- `eventstream` is optional and only used in phase 2. +- The connector re-reads the block every time the settings are refreshed and rebuilds its + clients when something changed (the same approach `ClickHouseClientPool` uses). This is needed + because `TenantAddedEvent` only fires for brand new tenants. When a tenant is removed, the + connector closes its clients. It never deletes anything in OneLake. + +On the customer side: register an app in Microsoft Entra ID, give it Contributor on the Fabric +workspace, and enable the tenant setting *Service principals can use Fabric APIs*. + +The connector signs in to Entra ID with the app's client id and secret. It asks for a token for +`https://storage.azure.com/.default` to write to OneLake and `https://api.fabric.microsoft.com/.default` +for the Fabric REST API. Tokens are cached per tenant and refreshed early. On HTTP 429 and 503 +it waits as long as the `Retry-After` header says. Outbound access to `login.microsoftonline.com` +and `onelake.dfs.fabric.microsoft.com` is new for our services and must be allowed wherever +outbound traffic is restricted. + +### Phase 1: Open Mirroring exporter + +```mermaid +flowchart LR + subgraph DataHub + API[datahub-api] + PG[(Postgres
authoritative)] + CH[(ClickHouse
datapoints, events)] + Vault[(Vault
tenant-resources.fabric)] + subgraph Pulsar + GT[/global ingest topics
all-datapoints, resources, events/] + ET[/per-customer topic
fabric/export/] + end + subgraph Connector[datahub-fabric-connector] + FWD[Forwarder
filter on tenantId property,
ack immediately] + WR[Writer, one per tenant
exclusive lock, batch, upload, ack] + end + ST[(fabric_export_state
per tenant schema)] + end + + subgraph Entra[Microsoft Entra ID] + TOK[token endpoint
client credentials] + end + + subgraph Fabric[Microsoft Fabric] + LZ[OneLake landing zone
Files/LandingZone/datahub.schema/*] + MDB[Mirrored database
Delta tables] + PBI[Power BI DirectLake] + SQL[SQL endpoint] + DTB[Digital Twin Builder] + end + + API -- writes --> PG + API -- writes --> CH + API -- publishes after commit --> GT + GT --> FWD + FWD --> ET + ET --> WR + PG -- snapshot and dirty-id re-read --> WR + CH -- CSV snapshots --> WR + Vault -- per-tenant config --> WR + TOK -- bearer token --> WR + WR -- ADLS Gen2 API: create, append, flush --> LZ + WR <-- next sequence, status --> ST + API -- GET /tenant/integrations/fabric --> ST + LZ -- mirroring replicator --> MDB + MDB --> PBI + MDB --> SQL + MDB --> DTB +``` + +#### The tables + +The mirrored database gets a schema called `datahub` with six tables. Every table folder has a +`_metadata.json` file that names the key columns. + +| Table | Key | Other columns | +|---|---|---| +| `datasets` | `id` | `external_id`, `name`, `description`, `parent_ids` (JSON), `labels` (JSON), `metadata` (JSON), `created_time`, `last_updated_time` | +| `resources` | `id` | `external_id`, `name`, `description`, `type_label`, `labels` (JSON), `metadata` (JSON), `source`, `data_set_id`, `is_root`, `geo_location`, timestamps | +| `timeseries` | `id` | `external_id`, `name`, `description`, `unit_external_id`, `value_type`, `data_set_id`, `labels`, `metadata`, timestamps | +| `relationships` | `id` | `source_id`, `target_id`, `type`, `start`, `end`, `metadata` | +| `events` | `id` | `external_id`, `type`, `sub_type`, `status`, `source`, `description`, `event_time`, `related_resource_ids` (JSON), `data_set_id`, `metadata`, timestamps | +| `datapoints` | `timeseries_id`, `timestamp` | `value_float`, `value_bigint`, `value_decimal`, `value_text`. Only one is filled per row; `timeseries.value_type` says which. | + +ClickHouse keeps datapoints in one table per value type. We still export a single `datapoints` +table. Power BI and Digital Twin Builder both want one table, a view that unions several tables +would make Power BI fall back to a slower query mode, and empty columns cost almost nothing in +Parquet. Decimal values are exported as Double until Fabric's CSV schema supports a decimal +type; the docs will say so. + +Column rules: ids are 64-bit integers, timestamps are UTC with millisecond precision, maps and +lists are stored as JSON text (an Open Mirroring rule), and the last column is always +`__rowMarker__`. We always write marker `4` (upsert). Pulsar can deliver a message twice, and the +service can crash between uploading a file and acknowledging the messages; with upserts both +cases are harmless. Deletes use marker `2`. + +#### File format + +CSV compressed with zstd, with an explicit column schema (`SchemaDefinition`) in +`_metadata.json` for every table. The same format is used for the first full load and for every +update afterwards, so Fabric never has to guess column types and the two can never disagree. +`zstd-jni` is already a dependency, so nothing new is needed. + +- For `datapoints` and `events`, ClickHouse writes the CSV itself. The full load is split by + timeseries id range and by year so a failed upload only restarts one chunk. Query timeouts + must allow for streams of several gigabytes. +- For the entity tables, use plain SQL with keyset pagination (`id > :last ORDER BY id LIMIT n`) + and let Postgres build the JSON columns (`jsonb_object_agg` for metadata, `string_agg` for + labels). JPA is the wrong tool here: lazy collections mean one extra query per row, and the + routing datasource has a per-statement timeout. +- Parquet is a later optimisation. ClickHouse can write it directly (`FORMAT Parquet`; use + Snappy or ZSTD, because Fabric does not accept ClickHouse's default LZ4). If CSV turns out to + be a problem for the entity tables, a small Parquet writer without Hadoop is the fallback. + +#### How updates flow + +- A message about a resource, timeseries, dataset or relationship is treated as "this id + changed". The writer reads the current row from Postgres (inside `TenantContext.runWith`) and + writes the full row as an upsert. If the row is gone, it writes a delete. This keeps Postgres + as the single source of truth (see [CONSTRAINTS.md](CONSTRAINTS.md)). +- Event messages contain the full event, so they are written as they are. +- Datapoint messages do not say which dataset they belong to. The writer keeps a map from + timeseries id to dataset id per tenant. It builds the map at start and keeps it current from + the resource topic (including dataset changes and new `BELONGS_TO` relationships, which + re-run the closure query). Points for timeseries outside the allow-list are dropped. If a + timeseries moves into or out of the allow-list, its history is not backfilled or removed + automatically. That is documented, and there is an on-demand rebuild per timeseries. +- A periodic check against Postgres (`last_updated > watermark` for changes, an id comparison + against the connector's own ledger for deletes) catches anything the stream missed. The stream + makes things fast; the check makes them correct. Rebuilds are per timeseries or per year, never + the whole table. +- The `fabric/export` subscription is created before the full load starts. The overlap is safe + because every row is an upsert. +- All per-tenant work runs inside `TenantContext.runWith`, thread pools are wrapped with + `TenantContextExecutorService`, and the tenant is cleared afterwards. A headless app has no + `RequestStateCleanupFilter` to do that for us. + +#### State and status + +A `fabric_export_state` table in each tenant's schema (a new migration in `datahub-infra`) +stores, per exported table: the next file sequence number, full-load status, last upload time, +lag and last error. The sequence number is reserved and committed **before** the upload and never +reused after a crash. We cannot recover it by listing the landing zone, because Fabric moves +processed files away. + +`datahub-api` gets a read-only endpoint `GET /tenant/integrations/fabric` that returns that +table, only when the feature flag is on. The console can show status from it without a proxy. +This copies the existing pattern where `StatsService` reads counters written by another process, +rather than the unused `CDCIntegration` entities. + +The connector writes one `_partnerEvents.json` per mirrored database +(`partnerName: IntelliStream DataHub`, `sourceType: DataHub`). + +#### Changes elsewhere in the platform + +- Add a `tenantId` property to datapoint, resource and event messages (`TimeseriesService`, + `AfterCommitMessagePublisher`). Pulsar only decodes the message body on request, so the + forwarder can drop other tenants' messages cheaply. +- Make `ResourceService.delete` publish the resolved ids and the ids of the relationships that + were deleted with them, instead of the caller's request. The graph consumer benefits too. +- Datapoint deletes: `POST /timeseries/data/delete` should look up the affected keys before + publishing, with a cap; above the cap, mark the timeseries for rebuild. Deleting a whole + timeseries is not enumerated, because Fabric users join `datapoints` to `timeseries`, and the + orphans are removed by a scheduled rebuild. +- Add the `fabric` block to `Tenant` (`datahub-commons`) and the derived `fabric` flag to + `TenantFeatures`, with a `datahub.features.fabric` default and a note in the SDK docs for + `/tenant/features`. +- Move `PulsarReceiveLoop` from `datahub-stateful-consumer` to `datahub-infra` so the connector + can reuse it. +- Add a provisioner for the `fabric/export` topic next to the subscription fan-out provisioner. +- Alert when the connector's subscription backlog gets anywhere near the namespace limit. + +#### Digital Twin Builder + +A documentation deliverable, not code. It explains how to map `resources` (by `type_label` and +`labels`) to entity types, `relationships` to relationship types, `timeseries` rows to time +series properties, and `datapoints` as their data. Digital Twin Builder maps from lakehouse +tables in its own UI, and the mirrored database provides exactly those tables. + +### Phase 2: Eventstream bridge and write-back + +```mermaid +flowchart LR + subgraph DataHub + ET[/fabric/export
per-customer topic/] + WR[Writer
phase 1] + ESB[Eventstream bridge
second consumer] + API[datahub-api] + KC[Keycloak
OIDC broker for Entra ID
jwt-bearer for service principals] + Console[datahub-console] + end + + subgraph Fabric[Microsoft Fabric] + CE[Eventstream
custom endpoint] + EH[Eventhouse / KQL] + ACT[Activator alerts] + RTD[Real-time dashboards] + NB[Notebooks, pipelines,
Activator actions] + end + + subgraph Entra[Microsoft Entra ID] + SP[service principal] + USER[Fabric user] + end + + ET --> WR + ET --> ESB + ESB -- Event Hubs HTTPS send, or Kafka protocol --> CE + CE --> EH + CE --> ACT + CE --> RTD + + NB -- Entra token --> SP + SP -- assertion --> KC + KC -- DataHub token --> NB + NB -- write-back: datapoints, events --> API + + USER -- sign in with Entra ID --> KC + KC -- same identity, organization membership --> Console +``` + +- **Live data.** A second consumer on `fabric/export` sends datapoints and events to the + Eventstream custom endpoint. For events and moderate datapoint rates, the Event Hubs HTTPS send + API with a SAS key is enough (1 MB per batch, no new dependency). For high datapoint rates we + need the Kafka protocol (`kafka-clients`) or AMQP. The message format is the same JSON as the + `/timeseries/datapoints/listen` WebSocket, so there is one schema, and it can be registered in + Fabric's schema registry. +- **Writing back to DataHub** from Fabric notebooks, pipelines or Activator actions needs no + platform change. What it needs is: test the Entra ID to Keycloak token exchange end to end + ([EntraID.md](EntraID.md) has never been run and predates Keycloak Organizations), confirm + which identity a Fabric notebook can get a token for, publish the existing Python bindings, and + add a Fabric notebook example to the SDK docs. +- **Signing in with a Microsoft account.** Keycloak can use the customer's Entra ID as an + identity provider, create the user on first login and put them in the right organization. Then + Fabric users and console users are the same people. The token exchange path above needs one + pre-created Keycloak user per principal, which is fine for service accounts but not for humans. + +### Phase 3: Fabric workload (Extensibility Toolkit) + +```mermaid +flowchart LR + subgraph Browser[Fabric user's browser] + Portal[Fabric portal] + IF[DataHub workload frontend
our web app in an iframe] + end + + subgraph Entra[Microsoft Entra ID] + TOK[user token] + end + + subgraph Fabric[Microsoft Fabric] + HUB[Workload Hub
private preview, then listing] + WS[Workspace
DataHub item] + FAPI[Fabric REST API
create mirrored database] + MDB[Mirrored database] + end + + subgraph DataHub + KC[Keycloak
Entra ID brokered] + API[datahub-api
CORS for the Fabric origin] + ST[(fabric_export_state)] + WR[Writer
phase 1] + Console[datahub-console] + end + + HUB -- install --> Portal + Portal -- loads manifest, renders --> IF + Portal <-- item CRUD --> WS + TOK -- Entra token --> IF + IF -- exchange --> KC + KC -- DataHub token --> IF + IF -- pick tenant, datasets; status and lag --> API + API --> ST + IF -- create or attach --> FAPI + FAPI --> MDB + WR -- landing zone --> MDB + IF -- deep link --> Console +``` + +A `DataHub` item inside Fabric. The user picks a DataHub tenant and datasets, creates or +attaches the mirrored database, sees sync status and lag, and can jump to the console. We host +the web page; Fabric shows it in an iframe. It talks to the status endpoint and to `datahub-api`, +which needs CORS for the Fabric origin. The users are Microsoft accounts, so sign-in goes through +the Keycloak brokering from phase 2. The alternative, teaching `datahub-api` to accept Microsoft +tokens directly, would have to be repeated in `datahub-analysis` and needs audience checks that +do not exist today. Publish first as a private preview for a few customers, then to the Workload +Hub. + +### Phase 4: Power Query connector + +```mermaid +flowchart LR + subgraph Analyst[Analyst workstation] + PBID[Power BI Desktop] + MEZ[DataHub connector .mez
navigation table: datasets,
timeseries, resources, events;
aggregated datapoints function] + end + + subgraph Gateway[On-premises data gateway] + GMEZ[same .mez loaded
from the connector folder] + end + + subgraph Fabric[Microsoft Fabric] + DF[Dataflow Gen2] + PBS[Power BI service
scheduled refresh] + end + + subgraph DataHub + KC[Keycloak
auth code + PKCE, public client] + API[datahub-api
REST, server-side aggregation] + OAS[openapi.json
generated at build, published with SDK docs] + end + + OAS -. informs .-> MEZ + PBID --> MEZ + MEZ -- OAuth2 sign-in --> KC + KC -- token --> MEZ + MEZ -- /timeseries/data/list, /resources/filter, ... --> API + DF --> GMEZ + PBS --> GMEZ + GMEZ -- same flow --> API +``` + +Lowest priority. Once the tables are mirrored, Power BI can read them directly, and Dataflow +Gen2 only loads custom connectors through the gateway. The one thing the connector adds is Power +BI Desktop against the live API, with aggregation done on the server. It lives in its own +repository, signs in with OAuth2 authorization code + PKCE against Keycloak, and needs a licence +decision. Before it can be built, we need to generate `openapi.json` at build time and publish it +with the SDK docs. + +## Order of work + +1. The small producer-side changes from phase 1 (tenant property, resolved delete ids, datapoint + delete keys), each as its own pull request. +2. The connector: forwarder and topic provisioner, then entity tables (full load and updates), + then datapoints and events. +3. Status endpoint, operator docs, Digital Twin Builder guide. +4. Phase 2, then 3 and 4 when a customer asks for them. + +## Open questions + +1. **Granularity.** One mirrored database per tenant and allow-list, with Fabric's own workspace + permissions from there. Do we also need one mirrored database per dataset? Several `fabric` + blocks per tenant would allow it. +2. **History on dataset moves.** Rebuild on demand only, or automatically when a timeseries + enters the allow-list? +3. **Checking against Fabric.** The mirrored database's SQL endpoint uses the TDS protocol, which + we cannot reach with `HttpClient`. We can read the Delta log through the OneLake API to get + row counts, or we can trust our own ledger plus the Postgres check. The proposal is the + latter. +4. **Product.** A paid add-on like `chat` (flag off by default), or part of the platform? +5. **Sequence gaps.** Test on a real Fabric workspace that a skipped file number after a crash is + accepted. If not, switch the tables to `LastUpdateTimeFileDetection`. +6. **Event Hubs over HTTPS.** Confirm that the Eventstream custom endpoint accepts the REST send + API before relying on it in phase 2. + +## Out of scope + +See [the stories we are not taking on](#stories-we-are-deliberately-not-taking-on): no DataHub +files in OneLake, no DataHub queries against Fabric, and no exact propagation of +whole-timeseries deletes. + +## Documentation + +Operators (datahub-docs): registering the Entra app, the Vault block, the feature flag, running +and monitoring the connector, outbound network access. Developers (datahub-sdk-docs): the new +`fabric` entry in `/tenant/features`, Fabric notebook and write-back examples, the Digital Twin +Builder guide, and later the Power Query connector. + +## Sources + +- [OneLake, the unified data lake](https://learn.microsoft.com/en-us/fabric/onelake/onelake-overview) +- [Use Iceberg tables with OneLake](https://learn.microsoft.com/en-us/fabric/onelake/onelake-iceberg-tables) +- [Mirroring overview, including cost](https://learn.microsoft.com/en-us/fabric/mirroring/overview) +- [Fabric trial capacity](https://learn.microsoft.com/en-us/fabric/fundamentals/fabric-trial) +- [Fabric command line interface](https://learn.microsoft.com/en-us/rest/api/fabric/articles/fabric-command-line-interface) +- [Get started with the Extensibility Toolkit](https://learn.microsoft.com/en-us/fabric/extensibility-toolkit/get-started) +- [Develop a connector using the Power Query SDK](https://learn.microsoft.com/en-us/power-query/install-sdk) +- [Open Mirroring landing zone requirements and formats](https://learn.microsoft.com/en-us/fabric/mirroring/open-mirroring-landing-zone-format) +- [Open Mirroring partner ecosystem](https://learn.microsoft.com/en-us/fabric/mirroring/open-mirroring-partners-ecosystem) +- [Add a custom endpoint or custom app source to an eventstream](https://learn.microsoft.com/en-us/fabric/real-time-intelligence/event-streams/add-source-custom-app) +- [Microsoft Fabric Extensibility Toolkit](https://learn.microsoft.com/en-us/fabric/extensibility-toolkit/extensibility-toolkit-overview) +- [Workload Development Kit overview](https://learn.microsoft.com/en-us/fabric/workload-development-kit/development-kit-overview) +- [Digital Twin Builder: mapping data to entity types](https://learn.microsoft.com/en-us/fabric/real-time-intelligence/digital-twin-builder/concept-mapping) +- [Use custom data connectors with the on-premises data gateway](https://learn.microsoft.com/en-us/power-bi/connect-data/service-gateway-custom-connectors) +- [OneLake shortcuts](https://learn.microsoft.com/en-us/fabric/onelake/onelake-shortcuts)