Databricks.Solutions.Zerobus.Sdk
0.2.0
dotnet add package Databricks.Solutions.Zerobus.Sdk --version 0.2.0
NuGet\Install-Package Databricks.Solutions.Zerobus.Sdk -Version 0.2.0
<PackageReference Include="Databricks.Solutions.Zerobus.Sdk" Version="0.2.0" />
<PackageVersion Include="Databricks.Solutions.Zerobus.Sdk" Version="0.2.0" />
<PackageReference Include="Databricks.Solutions.Zerobus.Sdk" />
paket add Databricks.Solutions.Zerobus.Sdk --version 0.2.0
#r "nuget: Databricks.Solutions.Zerobus.Sdk, 0.2.0"
#:package Databricks.Solutions.Zerobus.Sdk@0.2.0
#addin nuget:?package=Databricks.Solutions.Zerobus.Sdk&version=0.2.0
#tool nuget:?package=Databricks.Solutions.Zerobus.Sdk&version=0.2.0
Databricks Zerobus .NET SDK
Contents
- Overview
- Installation
- Getting started
- High-throughput writes
- JSON ingestion
- Writing into a streaming table
- Generating a proto from a table
- Using the SDK in a service
- Working with a single stream
- Authentication
- Before you begin
- Limits
- Building from source
- Using this with an AI coding agent
- License
Overview
This repository provides a .NET client library for Databricks Zerobus, which ingests records directly into Unity Catalog managed Delta tables over gRPC. It is a managed library built on Grpc.Net.Client and Google.Protobuf, and it targets net10.0, net8.0, and netstandard2.1.
The SDK handles the connection, batching, acknowledgments, and reconnection for you, and exposes both a high-level bulk writer and a lower-level single stream.
Installation
dotnet add package Databricks.Solutions.Zerobus.Sdk
This writes the latest published version into your .csproj as a <PackageReference>.
Getting started
This example lands sensor readings into main.telemetry.sensor_readings.
1. Create the target table
Zerobus ingests into a table that already exists, so create it first:
CREATE TABLE main.telemetry.sensor_readings (
device_id STRING,
temp_c DOUBLE,
humidity INT,
reading_ts TIMESTAMP
);
2. Define the record schema
Describe a row as protobuf in Protos/sensor_reading.proto, matching the table columns:
syntax = "proto3";
option csharp_namespace = "MyApp.Telemetry";
message SensorReading {
string device_id = 1;
double temp_c = 2;
int32 humidity = 3;
int64 reading_ts = 4; // epoch microseconds
}
If you already have the generated C# class (the proto was compiled elsewhere), add nothing. The SDK depends on a current, patched Google.Protobuf that flows to your project transitively, which is all the runtime needs.
To have the build compile the .proto for you, add only Grpc.Tools (the build-time protoc), then point the build at the file:
dotnet add package Grpc.Tools
<ItemGroup>
<Protobuf Include="Protos/sensor_reading.proto" GrpcServices="None" />
</ItemGroup>
In the Grpc.Tools reference that dotnet add package wrote, add PrivateAssets="All" so it stays build-only. Don't add Google.Protobuf yourself: a version-less <PackageReference Include="Google.Protobuf" /> resolves to the old 3.0.0, which carries a known high-severity advisory. The transitive one from the SDK is patched. You can confirm the tree is clean with dotnet list package --vulnerable --include-transitive.
3. Write records
using Databricks.Solutions.Zerobus;
using MyApp.Telemetry;
await using var sdk = new ZerobusSdk(serverEndpoint, workspaceUrl);
await using var writer = await sdk.CreateBulkWriterAsync(
new TableProperties<SensorReading>("main.telemetry.sensor_readings"),
clientId, clientSecret);
await writer.WriteAsync(new SensorReading { DeviceId = "sensor-1", TempC = 22.5 }); // one record
await writer.WriteAsync(myReadings); // or an IEnumerable<SensorReading>
await writer.FlushAsync(); // returns once everything is stored
The writer batches and sends the records; FlushAsync waits until the server has them. await using flushes and closes the writer for you.
TableProperties<SensorReading> is the table name plus the record type. For JSON, use the non-generic new TableProperties("catalog.schema.table").
The connection values come from your workspace:
var serverEndpoint = "1234567890.zerobus.us-west-2.cloud.databricks.com"; // gRPC endpoint
var workspaceUrl = "https://dbc-xxxx.cloud.databricks.com"; // used for OAuth
var clientId = Environment.GetEnvironmentVariable("DATABRICKS_CLIENT_ID"); // service principal
var clientSecret = Environment.GetEnvironmentVariable("DATABRICKS_CLIENT_SECRET");
High-throughput writes
The same writer handles larger volumes. Keep calling WriteAsync as data comes in, then flush once at the end. Two settings on BulkWriterOptions control throughput:
| Option | Default | Description |
|---|---|---|
Parallelism |
4 | Number of connections running in parallel |
BatchSize |
10,000 | Maximum rows per batch (one gRPC message) |
MaxBatchBytes |
8 MB | Batches flush before this size to stay under the 10 MB message limit |
Here's a full example that writes a million records, passing the options as the last argument to CreateBulkWriterAsync:
using System.Diagnostics;
using Databricks.Solutions.Zerobus;
using MyApp.Telemetry;
// Connection settings come from your own config (env vars here). Same for Databricks-managed and Entra ID SPs.
var serverEndpoint = Environment.GetEnvironmentVariable("ZEROBUS_SERVER_ENDPOINT")!; // e.g. 1234567890.zerobus.us-west-2.cloud.databricks.com
var workspaceUrl = Environment.GetEnvironmentVariable("DATABRICKS_WORKSPACE_URL")!; // e.g. https://adb-xxxx.azuredatabricks.net
var clientId = Environment.GetEnvironmentVariable("DATABRICKS_CLIENT_ID")!; // service principal application (client) id
var clientSecret = Environment.GetEnvironmentVariable("DATABRICKS_CLIENT_SECRET")!; // its Databricks OAuth secret
await using var sdk = new ZerobusSdk(serverEndpoint, workspaceUrl);
var options = new BulkWriterOptions
{
Parallelism = 8, // 8 connections in parallel
BatchSize = 10_000, // rows per batch
};
await using var writer = await sdk.CreateBulkWriterAsync(
new TableProperties<SensorReading>("main.telemetry.sensor_readings"),
clientId, clientSecret, options);
// Your records can come from anywhere: a list, a query result, a file. This one streams
// them lazily, so they don't all sit in memory at once.
IEnumerable<SensorReading> readings = GenerateReadings(1_000_000);
var sw = Stopwatch.StartNew();
await writer.WriteAsync(readings); // the writer batches these and spreads them across the 8 connections
await writer.FlushAsync(); // returns once every record is stored
sw.Stop();
Console.WriteLine($"Wrote 1,000,000 records in {sw.Elapsed.TotalSeconds:F1}s");
static IEnumerable<SensorReading> GenerateReadings(int count)
{
for (var i = 0; i < count; i++)
yield return new SensorReading
{
DeviceId = $"sensor-{i % 100}",
TempC = 20 + (i % 15),
Humidity = 40 + (i % 30),
ReadingTs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1000,
};
}
A higher Parallelism gives more throughput, up to your network and account limits. With 8 connections this lands a million records in the tens of seconds (roughly 40,000+ rows per second from a single client). Each connection counts against your Zerobus concurrency quota, so pick a number you'll actually use.
💡 Tip: If you leave
optionsoff, the writer uses the defaults above, which work well for most cases.
JSON ingestion
If you'd rather not define a proto, send JSON instead. Everything else stays the same:
await using var writer = await sdk.CreateBulkWriterAsync(
new TableProperties("main.telemetry.events"), clientId, clientSecret);
await writer.WriteAsync("{\"device_id\":\"sensor-1\",\"temp_c\":22.5}"); // a JSON string
await writer.WriteAsync(new { device_id = "sensor-2", temp_c = 23.0 }); // or any object
await writer.FlushAsync();
Writing into a streaming table
Zerobus can also write into a Databricks streaming table, which is useful when a downstream Lakeflow pipeline or Structured Streaming job reads the data incrementally. Create it with CREATE STREAMING TABLE and a column list (no query), so it starts empty and Zerobus fills it:
CREATE STREAMING TABLE main.telemetry.sensor_readings (
device_id STRING,
temp_c DOUBLE,
humidity INT,
reading_ts TIMESTAMP
);
The SDK code doesn't change. Point TableProperties at the streaming table the same way you would a regular table:
await using var writer = await sdk.CreateBulkWriterAsync(
new TableProperties<SensorReading>("main.telemetry.sensor_readings"),
clientId, clientSecret);
await writer.WriteAsync(readings);
await writer.FlushAsync();
Downstream, you can read it as a streaming source, for example a CREATE STREAMING TABLE ... AS SELECT that aggregates it as new rows arrive.
Generating a proto from a table
Generating the proto is optional. The SDK ingests any compiled protobuf message, so you can write the .proto by hand (as shown above), match the table schema yourself, or use the official Databricks Python generator (python -m zerobus.tools.generate_proto).
This repo also bundles a generator that reads the table and keeps the fields in sync. It isn't published to NuGet, so run it from a clone:
git clone https://github.com/guanjieshen/zerobus-dotnet
dotnet run --project zerobus-dotnet/tools/Databricks.Solutions.Zerobus.ProtoGen -- \
--uc-endpoint https://adb-xxxx.azuredatabricks.net \
--client-id "$DATABRICKS_CLIENT_ID" \
--client-secret "$DATABRICKS_CLIENT_SECRET" \
--table main.telemetry.sensor_readings \
--output sensor_reading.proto \
--namespace MyApp.Telemetry
It marks every field optional so a value of 0, 0.0, or "" still gets sent (see the note in Before you begin).
Using the SDK in a service
Register the SDK once as a singleton and inject the IZerobusSdk interface where you need it. The gRPC channel is meant to be reused, and depending on the interface keeps your code easy to test:
builder.Services.AddSingleton<IZerobusSdk>(_ =>
new ZerobusSdk(config["Zerobus:ServerEndpoint"]!, config["Zerobus:WorkspaceUrl"]!));
public sealed class TelemetryIngestor(IZerobusSdk sdk)
{
public async Task IngestAsync(IEnumerable<SensorReading> readings, CancellationToken ct)
{
await using var writer = await sdk.CreateBulkWriterAsync(
new TableProperties<SensorReading>("main.telemetry.sensor_readings"),
clientId, clientSecret, cancellationToken: ct);
await writer.WriteAsync(readings, ct);
await writer.FlushAsync(ct);
}
}
💡 Tip: For a long-running service, keep one writer open and reuse it rather than creating one per request. Opening a stream costs an auth and handshake round trip.
Working with a single stream
For control over individual records, use a single stream instead of the bulk writer:
var stream = await sdk.CreateStreamAsync(
new TableProperties("main.telemetry.events"), clientId, clientSecret);
long offset = await stream.IngestRecordAsync("{\"device_id\":\"sensor-1\"}");
await stream.WaitForOffsetAsync(offset); // that record is now stored
await stream.CloseAsync();
A record is stored once the call that waits on it (WaitForOffsetAsync or FlushAsync) returns. Delivery is at-least-once: if a connection drops, the SDK reconnects and resends anything that wasn't confirmed, so expect the occasional duplicate downstream. If something fails for good, GetUnacknowledgedRecords() returns whatever didn't make it so you can retry it elsewhere.
Authentication
Zerobus needs a Databricks OAuth token that is scoped to the target table and issued by the workspace endpoint ({workspaceUrl}/oidc/v1/token). A raw Entra ID token from login.microsoftonline.com is not accepted directly. The SDK ships a token provider for each supported credential, and every provider caches the token and refreshes it shortly before expiry.
Pick the method that matches where your code runs:
| Method | When to use | Provider |
|---|---|---|
| Databricks service principal | Default. A Databricks-managed SP with a client id and secret. | built in (pass clientId, clientSecret) |
| Microsoft Entra ID service principal | You authenticate with an Entra ID (Azure AD) SP. | built in, or FederatedTokenProvider |
| Azure managed identity | Code runs on Azure (Functions, App Service, VM) with no secret to manage. | ManagedIdentityTokenProvider |
| Your own token | You already have a Databricks token from another flow. | DelegatingTokenProvider / ITokenProvider |
Whatever the method, the service principal or identity needs USE CATALOG, USE SCHEMA, and SELECT + MODIFY on the target table (see Before you begin).
Databricks service principal (client credentials)
This is the default and covers most apps: an OAuth 2.0 client-credentials (machine-to-machine) flow with a Databricks-managed service principal.
Setup:
- Create a service principal: account console (or workspace) Settings, Identity and access, Service principals, Add service principal.
- Generate an OAuth secret for it: the SP's Secrets, Generate secret. Copy the client ID (application ID) and secret.
- Grant the SP
USE CATALOG/USE SCHEMA/SELECT+MODIFYon the target table.
Pass the client id and secret directly (this is the flow used in Getting started):
await using var sdk = new ZerobusSdk(serverEndpoint, workspaceUrl);
await using var writer = await sdk.CreateBulkWriterAsync(
new TableProperties<SensorReading>("main.telemetry.sensor_readings"),
clientId, clientSecret);
Under the hood this uses OAuthTokenProvider, which requests the table-scoped token with HTTP Basic auth. You can construct it yourself if you want to share one provider across streams.
Microsoft Entra ID service principal (client credentials)
You can authenticate with a Microsoft Entra ID (Azure AD) service principal in one of two ways.
Option A (recommended): give the Entra SP a Databricks OAuth secret. Add the Entra ID SP to the workspace and generate a Databricks OAuth secret for it (Settings, Identity and access, Service principals, Secrets), then use the exact same client-credentials flow as above. No tenant id is needed, the token request is identical to a Databricks-managed SP, and it is the endpoint Databricks recommends for M2M.
await using var sdk = new ZerobusSdk(serverEndpoint, workspaceUrl);
await using var writer = await sdk.CreateBulkWriterAsync(
new TableProperties<SensorReading>("main.telemetry.sensor_readings"),
clientId, // the Entra SP application (client) id
clientSecret); // the Databricks OAuth secret you generated above
Option B: token federation (no Databricks secret). If you can't or don't want to issue a Databricks OAuth secret, authenticate the Entra SP with its own Entra client secret and let Databricks exchange it via token federation. You get an Entra token; FederatedTokenProvider exchanges it (RFC 8693) at the workspace endpoint for a Zerobus-scoped Databricks token, and no Databricks secret is stored.
One-time setup (account admin): create a federation policy so Databricks trusts the SP's Entra tokens.
- In the account console, go to User management, Service principals, your SP, Credentials & secrets, Federation policies, Create policy (or run
databricks account service-principal-federation-policy create). - Set:
- Issuer:
https://sts.windows.net/<tenant-id>/ - Audience:
2ff814a6-3304-4ab8-85cb-cd0e6f879c1d(the Azure Databricks application id, the same for every tenant) - Subject: the service principal's object id (the
subclaim of its Entra token) - Subject claim:
sub
- Issuer:
- Grant the SP
MODIFYandSELECTon the target table (see Before you begin).
💡 Tip: If you aren't sure of the subject, run a write once. When the policy is missing or wrong, Databricks returns the exact issuer, subject, and audience it expects in the error, which you can paste into the policy.
In code, get the Entra token with Azure.Identity and hand it to FederatedTokenProvider:
using Azure.Core;
using Azure.Identity;
using Databricks.Solutions.Zerobus;
var credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
await using var sdk = new ZerobusSdk(serverEndpoint, workspaceUrl);
var tokenProvider = new FederatedTokenProvider(
workspaceUrl,
ZerobusSdk.WorkspaceIdFromServerEndpoint(serverEndpoint),
subjectTokenProvider: async ct =>
(await credential.GetTokenAsync(
new TokenRequestContext(new[] { "2ff814a6-3304-4ab8-85cb-cd0e6f879c1d/.default" }), ct)).Token,
clientId: clientId); // the SP application (client) id, for service-principal federation policies
await using var writer = await sdk.CreateBulkWriterAsync(
new TableProperties<SensorReading>("main.telemetry.sensor_readings"), tokenProvider);
The provider does the token exchange and refreshes as needed. If you'd rather not add Azure.Identity, your subjectTokenProvider can POST to https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token (grant_type client_credentials, scope 2ff814a6-3304-4ab8-85cb-cd0e6f879c1d/.default) and return the access_token.
Azure managed identity (Functions, App Service, VM)
If your code runs on Azure with a managed identity, ManagedIdentityTokenProvider lets you ingest with no secret. It fetches the managed identity's Entra token and runs the same federation exchange as Option B above. It is dependency-free (no Azure.Identity) and reads the identity endpoint directly: the IDENTITY_ENDPOINT / IDENTITY_HEADER variables that Azure Functions and App Service expose, or the instance metadata service on a VM.
Setup: create the same kind of token-federation policy as Option B, but keyed to the managed identity:
- Issuer:
https://login.microsoftonline.com/<tenant-id>/v2.0 - Audience:
2ff814a6-3304-4ab8-85cb-cd0e6f879c1d(the Azure Databricks application id) - Subject: the managed identity's object (principal) id
- Grant the identity
USE CATALOG/USE SCHEMA/SELECT+MODIFYon the target table.
await using var sdk = new ZerobusSdk(serverEndpoint, workspaceUrl);
var tokenProvider = new ManagedIdentityTokenProvider(
workspaceUrl,
ZerobusSdk.WorkspaceIdFromServerEndpoint(serverEndpoint));
// For a user-assigned identity, also pass: managedIdentityClientId: "<mi-client-id>"
await using var writer = await sdk.CreateBulkWriterAsync(
new TableProperties<SensorReading>("main.telemetry.sensor_readings"), tokenProvider);
💡 Tip: The
examples/Databricks.Solutions.Zerobus.Examples.Functionsproject wires this up behindZEROBUS_AUTH_MODE=managed-identity, so you can deploy it to a Function to see the flow end to end.
This built-in provider covers Azure Functions, App Service, and VMs. For AKS workload identity, Azure Arc, or local development, get the Entra token with Azure.Identity (DefaultAzureCredential) and pass it to FederatedTokenProvider instead.
Supplying your own token
If you already obtain a Databricks token from another flow (the Databricks SDK, a token you hold, or a credential type not covered above), wrap it in DelegatingTokenProvider and pass that in place of the client id and secret:
var tokenProvider = new DelegatingTokenProvider(ct => GetMyDatabricksTokenAsync(ct));
await using var writer = await sdk.CreateBulkWriterAsync(
new TableProperties<SensorReading>("main.telemetry.sensor_readings"), tokenProvider);
For full control, implement ITokenProvider directly.
Before you begin
Since you create the table yourself, two things commonly get in the way:
⚠️ CHECK constraints are not supported. Zerobus will not ingest into a table that has CHECK constraints. Validate values in your producer instead.
⚠️ proto3 drops default values. A field equal to its default (
0,0.0,"") is not sent over the wire, and the server reads that as missing, so aNOT NULLcolumn rejects it. If a required field can be zero or empty, mark itoptionalin the proto and always set it. The proto generator does this for you.
⚠️ The table can't be in Unity Catalog default storage. Zerobus rejects a table whose catalog has no explicit managed or external storage location (error 4024, "Tables created in default storage are not supported"). Create the table in a catalog backed by managed or external storage.
The service principal needs access to the table:
GRANT USE CATALOG ON CATALOG main TO `<sp-client-id>`;
GRANT USE SCHEMA ON SCHEMA main.telemetry TO `<sp-client-id>`;
GRANT MODIFY, SELECT ON TABLE main.telemetry.sensor_readings TO `<sp-client-id>`;
For custom authentication, implement ITokenProvider and pass it in place of the client id and secret.
Limits
10 MB per message and 2,000 columns per table. The bulk writer keeps batches under the message limit for you, and you can scale past a single stream by raising Parallelism.
Building from source
Requires the .NET 10 SDK (pinned in global.json). The tests run on both the net8.0 and net10.0 target frameworks, so the .NET 8 runtime is also needed to exercise that target.
dotnet build -c Release
dotnet test # runs against an in-memory gRPC server, no credentials needed
The examples/ folder has JSON, protobuf, and Azure Functions samples that read settings from environment variables.
Using this with an AI coding agent
This repo ships a SKILL.md that follows the Agent Skills spec. Point your coding harness (Claude Code or similar) at it and the agent can wire Zerobus into your .NET project for you: install the package, set up the writer, configure authentication, and check the table requirements. Drop SKILL.md into your harness's skills directory, or copy it alongside your project so the agent picks it up.
License
Apache 2.0. See LICENSE.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Core | netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.1 is compatible. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.1
- Google.Protobuf (>= 3.27.2)
- Grpc.Net.Client (>= 2.65.0)
- System.Text.Json (>= 8.0.5)
- System.Threading.Channels (>= 8.0.0)
-
net10.0
- Google.Protobuf (>= 3.27.2)
- Grpc.Net.Client (>= 2.65.0)
-
net8.0
- Google.Protobuf (>= 3.27.2)
- Grpc.Net.Client (>= 2.65.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.2.0 | 146 | 7/7/2026 |