API Introduction
How the Berserk query APIs work — protocols, streaming, result iterations, and multi-table results.
Berserk exposes two query protocols: gRPC (native streaming) and HTTP (ADX v2 REST). Both run KQL on the same engine; they differ in how results are delivered.
| gRPC | HTTP (ADX v2) | |
|---|---|---|
| Protocol | HTTP/2 streaming | HTTP/1.1 JSON |
| Endpoint | QueryService.ExecuteQuery | POST /v2/rest/query |
| Path on the gateway | /api/grpc/query.QueryService/ExecuteQuery | /v2/rest/query |
| Authentication | Authorization: Bearer <token> | Authorization: Bearer <token> |
| Streaming | Server-side streaming (frames) | Chunked (progressive) or batch |
| Progress | Real-time via Progress frames | Optional via progressive mode |
| Best for | Applications, CLIs, services | Grafana, Kusto tooling, browsers |
Connecting
Clients connect to the gateway, the cluster's public edge, never to a query pod. The gateway authenticates the bearer token — a CLI access token from bzrk login or a service account's token — and forwards the request to the query service with a token the gateway signed. The query service refuses any query request without one: 401 over HTTP, UNAUTHENTICATED over gRPC.
The gateway forwards these query surfaces:
- gRPC under
/api/grpc/, as/api/grpc/<package>.<Service>/<Method>. For example/api/grpc/query.QueryService/ExecuteQuery. - HTTP: the Kusto REST endpoints
POST /v1/rest/queryandPOST /v2/rest/query, at the gateway's root.
The query service's other HTTP routes are internal; the gateway does not forward them.
gRPC Streaming Protocol
The gRPC API returns a server-side stream of typed frames. Each frame carries one payload:
Schema → RowBatch → RowBatch → Metadata → Progress → RowBatch → Metadata → Progress → CompletionFrame Types
| Frame | Purpose |
|---|---|
| Schema | Column definitions for a table. Sent once per table name. |
| RowBatch | Rows for a table. Multiple batches may be sent per table. |
| Progress | Execution statistics (rows scanned, chunks processed, timing). |
| Error | Query error with code, message, and source location. |
| Metadata | Everything a snapshot says besides its rows. Closes the snapshot. |
| Completion | Marks the end of the stream. |
Result Iterations
For long-running queries, the engine delivers incremental results as data is processed. Each update is a whole snapshot of the answer so far, named by a result_iteration_id on the frame — a UUID that changes with every snapshot.
When the id changes, discard all previously accumulated rows and start fresh. Each snapshot ends with one metadata frame carrying its warnings, advice and partial failures; replace what you hold with it, even when it is empty.
Snapshot "abc-123":
RowBatch(table="PrimaryResult", rows=[...], is_iteration_complete=false)
RowBatch(table="PrimaryResult", rows=[...], is_iteration_complete=true)
Metadata(warnings=[...])
Snapshot "def-456": ← new id → clear all previous rows
RowBatch(table="PrimaryResult", rows=[...], is_iteration_complete=true)
Metadata() ← no warnings now: the earlier ones are goneRowBatch Concatenation
A single result iteration may produce multiple RowBatch frames for the same table because a gRPC frame is limited to ~4 MiB. The server packs rows into a batch until the encoded frame would exceed that limit and then starts the next batch, so rows per batch depend on row size, not on a fixed row count. A single row too large to fit a frame is dropped, with a warning.
Concatenate all RowBatch frames that share the same table_name and result_iteration_id:
Batch 1: table="PrimaryResult", iteration="abc", rows=[row0..row999], is_iteration_complete=false
Batch 2: table="PrimaryResult", iteration="abc", rows=[row1000..row1234], is_iteration_complete=true
→ Result: 1235 rowsThe is_iteration_complete flag on the last batch signals that no more batches will arrive for this table in this snapshot. Clients should:
- Show the first batch immediately
- Accumulate subsequent batches
- Treat the snapshot as whole once its metadata frame arrives
- On a new snapshot id, clear and restart
Multi-Table Results
Some queries produce multiple tables (e.g., fork queries or queries with extra diagnostic tables):
- PrimaryResult — the main result table (always first)
- ExtraTable_0, ExtraTable_1, ... — additional tables
- Fork queries use branch names (e.g.,
"Totals","TopTwo")
Schema frames are sent once per table name. RowBatch frames reference their table by table_name. When an iteration ID changes, all tables are cleared together — not individually.
Schema(name="PrimaryResult", columns=[...])
Schema(name="Totals", columns=[...])
RowBatch(table="PrimaryResult", iteration="abc", rows=[...])
RowBatch(table="Totals", iteration="abc", rows=[...])Progress Frames
Progress frames are sent periodically during execution, independent of row delivery. They contain:
- Scan statistics: rows processed, chunks total/scanned/skipped
- Skip reasons: range, bloom filter, shard (hash), required fields
- Timing: chunk scan time, query time, merge time (nanoseconds)
- Bin progress: per-bin completion for
summarize ... by bin()queries - Planning progress: segment planning completion count
- Operator diagnostics: per-operator key-value telemetry
Use the latest progress frame — each one represents cumulative statistics.
Metadata Frame
Exactly one metadata frame closes every snapshot, after its row batches. It is whole — sent even when empty — and replaces the previous one, so a warning a later snapshot does not repeat is gone.
- Visualization: type and properties from the
renderoperator (e.g.,timechart,piechart) - Warnings: execution warnings like "summarize memory limit reached" or "result truncated"
- Partial failures: how many segments could not be read (
segment_count) and why. The legacysegment_idsfield is always empty.
Headers
| Header | Description |
|---|---|
authorization | Bearer <token>. Required. |
x-bzrk-username | User identification |
x-bzrk-client-name | Client identifier (e.g., berserk-ui, bzrk-cli) |
grpc-timeout | Request deadline (optional; absent means the query runs with no deadline) |
Example: Processing a gRPC Stream
use tokio_stream::StreamExt;
let mut stream = client.execute_query(request).await?.into_inner();
let mut tables: HashMap<String, Vec<Row>> = HashMap::new();
let mut current_iteration: Option<String> = None;
while let Some(frame) = stream.next().await {
let frame = frame?;
match frame.payload {
Some(Payload::Schema(s)) => { /* store column definitions */ }
Some(Payload::Batch(b)) => {
// New iteration? Clear everything.
if current_iteration.as_ref() != Some(&b.result_iteration_id) {
tables.clear();
current_iteration = Some(b.result_iteration_id.clone());
}
// Append rows to the correct table
tables.entry(b.table_name).or_default().extend(b.rows);
}
Some(Payload::Progress(p)) => { /* update stats display */ }
Some(Payload::Error(e)) => { /* handle error */ }
Some(Payload::Done(_)) => break,
_ => {}
}
}HTTP REST Protocol (ADX v2)
The HTTP API implements the Kusto v2 frame format, making it compatible with Azure Data Explorer tooling (Grafana ADX plugin, Kusto SDKs, etc.).
Send requests to the gateway, which mounts the endpoint at its root and requires a bearer token (a CLI access token or a service account's token). Anonymous calls get 401 with a WWW-Authenticate: Bearer challenge — never a login redirect. A non-empty db is required.
Non-Progressive Mode (Default)
Returns all frames in a single JSON array:
curl -X POST https://<gateway>/v2/rest/query \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-d '{"db": "default", "csl": "my_table | take 10"}'Response:
[
{"FrameType": "DataSetHeader", "IsProgressive": false, "Version": "v2.0"},
{"FrameType": "DataTable", "TableKind": "QueryProperties", "TableName": "@ExtendedProperties", ...},
{"FrameType": "DataTable", "TableKind": "PrimaryResult", "TableName": "PrimaryResult", "Columns": [...], "Rows": [...]},
{"FrameType": "DataTable", "TableKind": "QueryCompletionInformation", ...},
{"FrameType": "DataSetCompletion", "HasErrors": false}
]| Frame | Description |
|---|---|
DataSetHeader | Protocol version and mode flags |
DataTable (QueryProperties) | @ExtendedProperties — visualization metadata |
DataTable (PrimaryResult) | Query result rows and schema |
DataTable (QueryCompletionInformation) | Execution stats in Kusto format. Partial failures (segments that couldn't be read) appear here as Error-level rows with the failure message and the number of affected segments in the Payload column. |
DataSetCompletion | HasErrors flag — flipped to true when the response carries partial failures. Marks end of response. |
Progressive Mode
Enabled by setting results_progressive_enabled: true in request properties. Streams frames via chunked HTTP transfer:
curl -X POST https://<gateway>/v2/rest/query \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-d '{
"db": "default",
"csl": "my_table | summarize count() by bin(timestamp, 1m)",
"properties": {"Options": {"results_progressive_enabled": true}}
}'Progressive frames use replace semantics — each TableFragment supersedes all previous fragments for the same table:
{"FrameType": "DataSetHeader", "IsProgressive": true, "IsFragmented": true}
{"FrameType": "TableHeader", "TableId": 0, "TableName": "PrimaryResult", "Columns": [...]}
{"FrameType": "TableFragment", "TableId": 0, "TableFragmentType": "DataReplace", "Rows": [[...]]}
{"FrameType": "TableProgress", "TableId": 0, "TableProgress": 45.0}
{"FrameType": "TableFragment", "TableId": 0, "TableFragmentType": "DataReplace", "Rows": [[...]]}
{"FrameType": "TableProgress", "TableId": 0, "TableProgress": 100.0}
{"FrameType": "TableCompletion", "TableId": 0, "RowCount": 150}
{"FrameType": "DataSetCompletion", "HasErrors": false}Errors
A query-level failure (parse error, bind error, execution error) that happens before the response body starts streaming returns HTTP 4xx with a JSON error body, not v2 frames:
{
"type": "https://api.bzrk.dev/problems/query-error",
"message": "Syntax error: ...",
"code": "ParseError",
"error_details": { "message": "..." }
}Failing to resolve the database uses a different envelope, with the HTTP status repeated in the body:
{
"error": {
"code": "DatabaseNotFound",
"message": "database 'nope' not found",
"@type": "BzrkClient/v1"
},
"status": 404
}In progressive mode a failure that happens after frames have already been sent cannot change the HTTP status. The response ends instead with a DataSetCompletion frame whose HasErrors is true.
Headers
| Header | Description |
|---|---|
Authorization | Bearer <token>. Required. |
x-bzrk-username | User identification |
x-bzrk-client-name | Client identifier |
x-ms-client-request-id | Request correlation ID (echoed in completion stats) |
Column Types
Both protocols use the same type system:
| Type | Proto Enum | Kusto Name | Description |
|---|---|---|---|
| bool | COLUMN_TYPE_BOOL | bool | Boolean |
| int | COLUMN_TYPE_INT | int | 32-bit signed integer |
| long | COLUMN_TYPE_LONG | long | 64-bit signed integer |
| real | COLUMN_TYPE_REAL | real | 64-bit IEEE 754 float |
| string | COLUMN_TYPE_STRING | string | UTF-8 string |
| datetime | COLUMN_TYPE_DATETIME | datetime | Timestamp (nanoseconds since the Unix epoch) |
| timespan | COLUMN_TYPE_TIMESPAN | timespan | Duration (100ns ticks) |
| guid | COLUMN_TYPE_GUID | guid | UUID string |
| dynamic | COLUMN_TYPE_DYNAMIC | dynamic | JSON-like nested value |
Dynamic Values (gRPC)
In the gRPC protocol, cell values are encoded as BqlValue protobuf messages with a oneof for each scalar type plus arrays and property bags. See the proto definition for the full schema.
In the HTTP protocol, values are JSON-native — strings, numbers, booleans, nulls, arrays, and objects.