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 execute KQL queries against 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 |
| Port | 9510 | 9510 |
| 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 |
gRPC Streaming Protocol
The gRPC API returns a server-side stream of typed frames. Each frame carries one payload:
Schema → RowBatch → RowBatch → Progress → RowBatch → Metadata → 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 | Warnings, partial failures, and visualization hints. |
| 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 identified by a result_iteration_id — a UUID that changes with every new set of results.
When the iteration ID changes, discard all previously accumulated rows and start fresh. This is how the engine signals "here is a more complete result set that supersedes the previous one."
Iteration "abc-123":
RowBatch(table="PrimaryResult", rows=[...], is_iteration_complete=false)
RowBatch(table="PrimaryResult", rows=[...], is_iteration_complete=true)
Iteration "def-456": ← new ID → clear all previous rows
RowBatch(table="PrimaryResult", rows=[...], is_iteration_complete=true)RowBatch Concatenation
A single result iteration may produce multiple RowBatch frames for the same table because gRPC messages are limited to ~4 MiB and batches are capped at 100 rows.
Concatenate all RowBatch frames that share the same table_name and result_iteration_id:
Batch 1: table="PrimaryResult", iteration="abc", rows=[row0..row99], is_iteration_complete=false
Batch 2: table="PrimaryResult", iteration="abc", rows=[row100..row150], is_iteration_complete=true
→ Result: 151 rowsThe is_iteration_complete flag on the last batch signals that no more batches will arrive for this iteration. Clients should:
- Show the first batch immediately (fast feedback)
- Accumulate subsequent batches
- Render the full table once
is_iteration_complete=true - On new iteration 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, shar (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
Sent after the primary table schema, the metadata frame carries:
- Visualization: type and properties from the
renderoperator (e.g.,timechart,piechart) - Warnings: execution warnings like "summarize memory limit reached" or "result truncated"
- Partial failures: segments that couldn't be read, with IDs and error messages
Headers
| Header | Description |
|---|---|
x-bzrk-username | User identification |
x-bzrk-client-name | Client identifier (e.g., berserk-ui, bzrk-cli) |
grpc-timeout | Request timeout (optional, defaults to 60 seconds) |
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,
_ => {}
}
}for {
frame, err := stream.Recv()
if err == io.EOF { break }
switch p := frame.Payload.(type) {
case *ExecuteQueryResultFrame_Schema:
// Store column definitions
case *ExecuteQueryResultFrame_Batch:
if currentIteration != p.Batch.ResultIterationId {
tables = map[string][]Row{} // clear all
currentIteration = p.Batch.ResultIterationId
}
tables[p.Batch.TableName] = append(tables[p.Batch.TableName], p.Batch.Rows...)
case *ExecuteQueryResultFrame_Progress:
// Update stats display
case *ExecuteQueryResultFrame_Error:
// Handle error
case *ExecuteQueryResultFrame_Done:
break
}
}async for frame in stub.ExecuteQuery(request, timeout=60):
payload = frame.WhichOneof("payload")
if payload == "schema":
# Store column definitions
elif payload == "batch":
if current_iteration != frame.batch.result_iteration_id:
tables.clear() # new iteration — discard old rows
current_iteration = frame.batch.result_iteration_id
tables[frame.batch.table_name].extend(frame.batch.rows)
elif payload == "progress":
# Update stats display
elif payload == "error":
raise QueryError(frame.error.code, frame.error.message)
elif payload == "done":
breakHTTP 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.).
Non-Progressive Mode (Default)
Returns all frames in a single JSON array:
curl -X POST http://localhost:9510/v2/rest/query \
-H 'Content-Type: application/json' \
-d '{"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 |
DataSetCompletion | HasErrors flag, 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 http://localhost:9510/v2/rest/query \
-H 'Content-Type: application/json' \
-d '{
"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
Both modes return HTTP 4xx with a JSON error body for query-level failures (parse errors, execution errors). The error is not wrapped in v2 frames:
{
"error": {
"code": "General_BadRequest",
"message": "Syntax error: ...",
"@type": "Kusto.Data.Exceptions.KustoBadRequestException"
}
}Headers
| Header | Description |
|---|---|
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_INT32 | int | 32-bit signed integer |
| long | COLUMN_TYPE_INT64 | long | 64-bit signed integer |
| real | COLUMN_TYPE_DOUBLE | real | 64-bit IEEE 754 float |
| string | COLUMN_TYPE_STRING | string | UTF-8 string |
| datetime | COLUMN_TYPE_DATETIME | datetime | Timestamp (100ns ticks) |
| 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 TTDynamic 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.