Skip to main content

gRPC protocol reference

For application code, use the .NET driver, TypeScript driver, or Python driver. The libraries expose connections, parameters, queries, and transactions through their own APIs. You do not need to generate Protobuf bindings or manage stream messages to use CamusDB.

The .NET and TypeScript drivers support gRPC as a transport option; Python currently uses REST. Selecting gRPC in a supported driver leaves encoding, authentication metadata, transaction handles, and stream management to the library. Use the retry helpers for retryable work.

When to use the gRPC API​

This page documents the public protocol for driver authors, custom clients, and transport diagnostics. It describes the wire contract behind the driver APIs. The client endpoint uses HTTP/2 on port 5096 by default and reaches the same SQL engine as the HTTP API.

Enable and configure the endpoint​

The endpoint is enabled by default. Configure or disable it in config.yml:

grpc_enabled: true
grpc_port: 5096
grpc_certificate:

Set grpc_certificate to a PFX certificate for the client-facing gRPC listener. If it is empty, CamusDB reuses a configured raft_certificate, the certificate of the node's internal cluster port. Without either certificate, the listener uses plaintext HTTP/2, which suits local development and private test networks.

Driver users configure credentials in their connection settings. A custom gRPC client uses the CamusAuth service to exchange a user name and password for a bearer token. gRPC clients then send that token in the request metadata:

authorization: Bearer camus_<id>.<secret>

Authentication failures return UNAUTHENTICATED, and privilege failures return PERMISSION_DENIED. See Authentication And Authorization.

The Protobuf contract lives in the CamusDB source tree at CamusDB.Grpc.Contracts/Protos/camus_sql.proto. Generate your client bindings from that file with the standard gRPC tools for your language.

Services​

The protocol defines three services:

ServiceUse it for
CamusSqlSQL queries, DML, DDL, explicit transactions, ping, and duplex batching.
CamusRowsTyped row CRUD without building SQL text.
CamusAuthLogin and logout for authenticated gRPC clients.

A driver or ORM integration normally uses CamusSql. Use CamusRows when the client already holds structured row values, filters, and ordering, and should not build SQL text.

Auth service​

CamusAuth provides the credential exchange used by gRPC-only deployments:

RPCPurpose
LoginAccepts LoginRequest { user, password } and returns LoginReply { token, expires_at_unix_ms, expires_in_seconds }.
LogoutRevokes the token supplied in authorization metadata. Missing or already-revoked tokens still produce the desired end state.

expires_at_unix_ms and expires_in_seconds describe the same deadline. Renew the token before that deadline, and don't assume a fixed token lifetime. An already-open BatchExecute stream can outlive ordinary token expiry when it authenticated while the token was valid; see gRPC batch streams and token expiry.

SQL service​

CamusSql provides:

RPCShapePurpose
ExecuteQueryserver streamExecute SELECT and SHOW; emits schema first, then rows.
ExecuteNonQueryunaryExecute INSERT, UPDATE, DELETE, and server-level account statements; returns affected rows, plus returning_schema and returning_rows for writes with RETURNING.
ExecuteDdlunaryExecute database, table, index, and schema statements.
StartTransactionunaryStart an explicit transaction and receive a TxnHandle.
CommitTransactionunaryCommit an explicit transaction by handle.
RollbackTransactionunaryRoll back an explicit transaction by handle.
BatchExecutebidirectional streamPipeline SQL operations, transaction lifecycle messages, and prepared-statement lifecycle messages on one stream.
PingunaryCheck liveness and round-trip connectivity.
GetNodeStatsunaryRead disk, CPU, memory, identity, and uptime for the node that receives the call. Requires superuser or MONITOR when authentication is enabled.

Clients send SQL parameters as a map<string, Value>. Prefer parameters to values inlined in the SQL string, so typed values such as DATE, DATETIME, BYTES, and UUID cross the wire without loss.

Account statements such as CREATE USER, ALTER USER, DROP USER, GRANT, and REVOKE name their target in the SQL and do not need an open database. They can run through unary SQL calls or through BatchExecute; bind passwords as parameters so secrets do not appear in SQL text.

SQL requests can also set routing_accept_version to opt in to advisory routing metadata. Set it to 1 when the client understands RoutingAdvice; leave it at 0 for the historical response shape. See SQL Routing Advice.

INSERT, UPDATE, and DELETE with a RETURNING list work through ExecuteQuery and ExecuteNonQuery. Through ExecuteQuery, the returned rows arrive as a normal query stream and the schema is sent only after the autocommit write commits. Through ExecuteNonQuery, the reply carries affected_rows, returning_schema, and returning_rows. Set discard_returning_rows on a no-rows call when the client only needs the count. See RETURNING.

Cancellation and backpressure​

Cancelling a gRPC call, closing a stream, or letting the deadline expire propagates cancellation into the query pipeline. CamusDB stops scans, joins, grouping, sorting, distinct, and subqueries at their next storage read or operator boundary instead of continuing work for a caller that has gone away.

BatchExecute also applies server-side backpressure; see Duplex batching.

Row service​

CamusRows provides typed CRUD operations:

RPCPurpose
InsertRowInsert one row from a column-value map.
QueryQuery rows by table, optional index name, filters, and ordering.
QueryByIdFetch one row by primary-key value.
UpdateRowsUpdate matching rows; at least one filter is required.
UpdateByIdUpdate one row by primary-key value.
DeleteRowsDelete matching rows; at least one filter is required.
DeleteByIdDelete one row by primary-key value.

A filter uses a QueryFilter { column_name, op, value }. The op is a string, such as "=", ">", ">=", "<", "<=", or "LIKE".

UpdateRows and DeleteRows without filters fail with INVALID_ARGUMENT and CADB0400, preventing accidental whole-table changes. To change all rows intentionally, use SQL with WHERE TRUE. A refused request also aborts its explicit transaction. See Failed statements.

An order uses an OrderBy { column_name, direction }, where the direction is ascending or descending.

QueryById, UpdateById, and DeleteById each take the key value as a string. The server resolves the actual primary-key column from the table schema, so the primary key doesn't have to be named id.

Row value maps reject column names that differ only in case with CADB0403. Identical duplicate wire keys are collapsed by the Protobuf map decoder before the engine sees them; clients must send each map key once.

Value encoding​

Every parameter, row value, filter, and result cell uses the Protobuf Value message, a typed oneof that follows the CamusDB column types:

Column typeWire fieldEncoding
NULLnull_valueExplicit typed NULL sentinel.
ID / OIDid_value24 lowercase ObjectId hex characters.
INT64int64_valueSigned 64-bit integer.
STRINGstring_valueUTF-8 string. Protobuf clients can replace invalid surrogate halves with U+FFFD before sending; validate text before encoding. See Unicode validation.
BOOLbool_valueBoolean.
FLOAT64float64_valueIEEE-754 double, including infinity and NaN.
FLOAT32float32_valueIEEE-754 float, including infinity and NaN.
BYTESbytes_valueRaw bytes.
DATEdate_valueUTC .NET ticks, truncated to midnight.
DATETIMEdatetime_valueUTC .NET ticks.
ARRAYarray_valueElement type plus nested Value items.
UUID / GUIDuuid_valueExactly 16 bytes in canonical big-endian order.
NUMERICnumeric_valueCanonical decimal text with at most 9 fractional digits, such as "-12.5".
JSONBjson_valueThe document text in the PostgreSQL form, such as {"a": [1, 2.50]}. Numbers are exact. A client built before this field existed reads a JSONB cell as NULL. See JSON documents.

If you write a client:

  • Send ObjectIds in id_value, not string_value.
  • Send UUIDs as 16 bytes, not as strings.
  • Send DATE and DATETIME as ticks, not ISO strings or Unix timestamps.
  • Preserve FLOAT32 and FLOAT64 as separate wire fields.
  • Include the array element type even when the array is empty.
  • Treat an unset Value and explicit null_value as NULL when decoding.

To convert Unix milliseconds to ticks:

ticks = 621355968000000000 + unix_millis * 10000

Query streams​

ExecuteQuery, CamusRows.Query, and CamusRows.QueryById are server streams, and all of them send the schema first:

QueryStreamMessage(schema)
QueryStreamMessage(row)
QueryStreamMessage(row)
...
[QueryStreamMessage(cache_metadata)]
[QueryStreamMessage(routing_advice)]

The schema message always comes first and appears exactly once, even for an empty result set.

Rows are positional: row.values[i] belongs to schema.columns[i].

A client must take each column's type from the schema, not from the first non-NULL value in a row.

A query with a {cache=...} hint can add one cache_metadata message after the last row. If the message is absent, the statement carried no cache hint.

A query that negotiated routing metadata can add one routing_advice message after the rows, and after cache metadata when both are present.

Transactions​

Every SQL and row operation runs either in autocommit mode or inside an explicit transaction.

An autocommit request omits txn_handle. The server starts a short transaction, runs the operation, and commits.

Explicit transactions use CamusSql.StartTransaction:

StartTransaction -> TxnHandle
ExecuteQuery / ExecuteNonQuery / ExecuteDdl with txn_handle
CommitTransaction(txn_handle)

StartTxnRequest and autocommit SqlRequest can set:

  • isolation_level: READ_COMMITTED or SERIALIZABLE
  • transaction_mode: READ_WRITE or READ_ONLY
  • locking: PESSIMISTIC or OPTIMISTIC
  • priority: BACKGROUND, LOW, NORMAL, HIGH, or CRITICAL

When a request resumes an existing txn_handle, the server ignores these fields, because the transaction's properties were fixed when it started.

TRANSACTION_PRIORITY_UNSPECIFIED means the server default priority, never background.

Row-level CRUD requests also accept priority when they start an autocommit transaction. See Transaction Priority for admission semantics and configuration.

SELECT ... FOR UPDATE is a query with write-lock effects: autocommit uses a writable transaction and releases locks before returning rows. An explicit transaction holds them until commit or rollback. Set READ_COMMITTED for SKIP LOCKED; read-only and optimistic transactions are refused. See row-locking SELECT.

Causal tokens​

Replies that advance transaction state include a causal token: a cluster timestamp with the three parts of a hybrid logical clock (HLC) value:

  • causal_token_n
  • causal_token_l
  • causal_token_c

Carry all three values into the next request of the same client session. The N component is part of the HLC ordering, so don't drop it.

Passing the token between requests lets a client read its own writes while it talks to a cluster.

Send the last commit's token on StartTxnRequest when beginning an explicit transaction, or on the SqlRequest for batch START. Read Committed statement snapshots are at least that token, so a node with a slower clock does not miss the preceding commit.

A transaction takes its causal floor once, when it starts. TxnHandle has causal-token fields, but the server does not read them, and the handle returned by StartTransaction does not populate them. Statements resuming an explicit transaction ignore top-level tokens too.

Autocommit queries read their snapshot at least as recent as their top-level SqlRequest token. Autocommit writes and DDL ignore that token; writes lock rows and read latest values. Read/write completion replies carry tokens for the next operation, while the start handle itself carries none.

Duplex batching​

CamusSql.BatchExecute lets a client send many operations over one bidirectional stream. It helps drivers and ORMs that would otherwise pay one unary round trip per statement.

Each request contains:

  • request_id: client-assigned id echoed by every response for that operation
  • kind: QUERY, NON_QUERY, START, COMMIT, ROLLBACK, PREPARE, or CLOSE; negotiated clients can also use FRAME, and frame contract version 2 clients can use COMMIT_IF_OK
  • request: the same SqlRequest shape used by unary SQL calls, absent on a frame
  • items: on a frame, a list of complete BatchExecuteRequest items
  • txn_start_ref: on an item inside a version 2 frame, the request_id of an earlier START item in that same frame

Responses for different request_id values can interleave and arrive out of order. A client must separate them by request_id.

A batched query emits:

schema
row...
query_complete

query_complete is the request's final message. It carries the row count and the causal token, plus the cache verdict for a query with a {cache=...} hint and, when the request negotiated it, advisory routing metadata.

Non-query, start, commit, and rollback operations each emit one final success response. A non-query response can also carry advisory routing metadata when the request negotiated it. A non-query response for a write with RETURNING can carry the returning schema and rows. A failed operation emits one last BatchError { code, message }.

Within one batch stream, operations that share a transaction handle keep their order. A client can use several batch streams, but it must put every operation of a transaction on the same stream. Autocommit operations can use any stream.

grpc_batch_max_in_flight controls how many operations one batch stream executes at the same time. Once a stream reaches that many in-flight operations, the server stops pulling more requests from the stream until work finishes.

Batch stream frames​

Batch stream frames let one BatchExecute stream message carry several complete operations, or several complete response messages. A frame is only a transport optimization. It does not make the operations atomic, and it does not add ordering beyond the order already provided by the stream.

A request frame uses kind = FRAME and puts the operations in items. The frame's own request_id is unused. Each item keeps its own request_id and is handled as if it arrived as a separate stream message. One item that fails does not affect its neighbors.

Frames are negotiated per stream:

  • The server sends response header camusdb-batch-frames: 2 when that stream can read request frames. Version 1 means ordinary frames. Version 2 also means the stream accepts txn_start_ref and COMMIT_IF_OK.
  • A client must not send a request frame until it has seen that header on the same stream. Older servers can treat an unknown kind as a non-query, so do not probe with a frame.
  • The server sends response frames only after the client proves it can read them. The client can send request header camusdb-batch-frames-accept: 1 when opening the stream, or it can send a request frame on that stream.

A frame can contain at most 256 items and at most 1 MiB of serialized item payload. The sender is responsible for staying inside those limits. A message larger than the frame byte budget travels as an ordinary single message.

Clients that do not negotiate frames continue to work with one operation per stream message and ordinary responses. A rotated or rebuilt stream negotiates again.

Frame contract version 2 lets a custom gRPC client pipeline a short transaction without waiting for the START reply before it sends the next statements. An item inside a frame can set txn_start_ref to the request_id of an earlier START item in that same frame instead of using a transaction handle that does not exist yet. The server chains those items behind the START in frame order, then the START reply returns the real handle for later frames.

The rules are strict: txn_start_ref must point backward to a START in the same frame, must not be combined with request.txn_handle, and must not appear on START, PREPARE, or CLOSE. A violation is rejected with CADB0400 without running the item. If the referenced START fails, every item that names it receives the same code and message and does not run.

COMMIT_IF_OK commits a transaction only when no earlier operation in that transaction on the same batch stream failed. If an earlier operation failed, it rolls the transaction back idempotently and returns the first failure's code and message. This lets a client send a bank-style transaction in two frames, such as [START, SELECT a, SELECT b] by txn_start_ref, followed by [UPDATE a, UPDATE b, COMMIT_IF_OK] by handle. A client may use txn_start_ref on COMMIT_IF_OK too when the whole transaction fits in one frame.

Only send txn_start_ref or COMMIT_IF_OK on a stream whose camusdb-batch-frames header announced version 2 or higher. A version 1 server does not understand the contract, so clients must fall back to awaiting START, sending statements by handle, and then sending COMMIT.

The server still counts each item in a frame as a request. Use the batch-frame metrics to see the transport-level effect: camus_batch_request_frames_total counts request frames, and camus_batch_request_frame_items records the number of operations carried by each frame.

Prepared statements​

Prepared statements are supported on CamusSql.BatchExecute.

Send a PREPARE operation with the target database and the SQL text. The final message, a PrepareReply, returns:

  • statement_id: an integer handle scoped to that batch stream
  • parameter_names: the positional binding order for placeholders

Then send a QUERY or NON_QUERY operation with the statement_id and positional_parameters. When you send a statement_id, don't also send SQL text, a database name, or a named-parameter map.

Send CLOSE to release a prepared statement id on the stream. A second CLOSE is harmless.

A handle belongs to one stream. It disappears when the BatchExecute stream closes or the client rebuilds it.

If an execution fails with CADB0520 UnknownPreparedStatement, prepare the statement again on the current stream and replay the operation once.

Wait for the PrepareReply before you execute with its id. Otherwise, because batch requests can run concurrently, the execution can reach the server before the registration.

Unary gRPC calls accept no prepared handle, because they have no stream scope. See Prepared Statements for supported statement types, binding rules, and configuration limits.

Errors and retries​

Unary and server-streaming RPCs report domain errors as gRPC status errors, with the details in trailer metadata:

TrailerMeaning
camus-error-codeCamusDB CADBxxxx error code.
camus-error-messageHuman-readable error message.

Batch operations use a BatchError message inside the stream, because a trailer belongs to a whole call, not to one operation.

The official libraries expose these as driver errors and provide retry helpers. Custom protocol clients base retries on camus-error-code:

CodeRetry rule
CADB0502 TransactionConflictReplay the whole transaction from a fresh BEGIN.
CADB0504 TransactionMustRetryReplay the whole transaction from a fresh BEGIN.
CADB0505 TransactionLifetimeExceededReplay the whole transaction from a fresh BEGIN.
CADB0509 TransactionFinalizeUnresolvedRetry the same COMMIT or ROLLBACK on the same transaction handle.
CADB0520 UnknownPreparedStatementPrepare again on the current node or stream, then replay the execution once.

Bad client input, including SQL syntax errors and CADB0413 StatementTooDeeplyNested, maps to gRPC INVALID_ARGUMENT.

For a streaming query, replay automatically only while the caller has seen no rows. After the first row reaches the caller, report the error instead of silently replaying the query.