Skip to main content

.NET driver

CamusDB.Client connects a .NET application to CamusDB. It is an ADO.NET provider. ADO.NET is the standard .NET database interface: connections, commands, data readers, and transactions work the same way for every database, so ADO.NET code you wrote for another database reads the same with CamusDB.

The driver is the foundation of the other .NET packages. It also gives you direct access to CamusDB features such as the gRPC transport, learned routing, prepared statements, serializable retries, and backups. You use ADO.NET methods and .NET values; the driver handles HTTP/JSON or gRPC encoding, authentication messages, and transaction handles.

Which .NET package to use​

PackageWhat it gives youChoose it whenTargets
CamusDB.ClientThe ADO.NET driver: connections, commands, data readers, and transactions.You want full control of the SQL text and of each command.net8.0, net9.0, net10.0
CamusDB.DapperDapper on the same connection: you write the SQL, and Dapper maps each row to a C# object.You write SQL yourself, but you do not want to read each column by hand.net8.0, net9.0, net10.0
CamusDB.EntityFrameworkCoreThe EF Core provider: LINQ queries, change tracking, and migrations.Your application uses C# classes as its data model, and EF Core writes the SQL.net10.0

All three packages share the same driver underneath, and you can use more than one in the same application.

For integration tests, Testcontainers starts a temporary CamusDB server in Docker and supplies connection strings for these packages. For local application orchestration, the .NET Aspire integrations connect your AppHost and services with database resources and client registration.

Use the driver directly when:

  • You want full control of the SQL text.
  • You use CamusDB SQL features that an ORM doesn't expose.
  • You want a small dependency without EF Core.
  • You want to control each transaction explicitly.

Install​

dotnet add package CamusDB.Client

Open a connection​

using CamusDB.Client;

await using CamusConnection connection =
new(new CamusConnectionStringBuilder(
"Endpoint=http://localhost:5095;Database=test"));

await connection.OpenAsync();

ChangeDatabase("otherdb") changes the connection's target database. The target database must already exist; see Database administration when the application is responsible for provisioning it.

Run DDL​

Use CreateCamusCommand(...) for SQL statements:

await using CamusCommand ddl = connection.CreateCamusCommand("""
CREATE TABLE robots (
id OID PRIMARY KEY NOT NULL,
name STRING NOT NULL,
kind STRING,
year INT64,
price FLOAT64,
enabled BOOL
)
""");

bool created = await ddl.ExecuteDDLAsync();

ExecuteDDLAsync() is also the direct path for other CamusDB DDL, such as:

  • CHECK constraints.
  • Named NOT NULL constraints.
  • Index operations.
  • Table renames.
  • TRUNCATE TABLE.
  • Schema changes that have no helper method.

Insert rows​

To insert rows, use the insert helper or parameterized SQL.

Insert helper​

using CamusDB.Core.Util.ObjectIds;

await using CamusInsertCommand insert = connection.CreateInsertCommand("robots");

insert.Parameters.Add("id", ColumnType.Id, CamusObjectIdGenerator.Generate());
insert.Parameters.Add("name", ColumnType.String, "T-800");
insert.Parameters.Add("kind", ColumnType.String, "cyborg");
insert.Parameters.Add("year", ColumnType.Integer64, 1984);
insert.Parameters.Add("price", ColumnType.Float64, 10.0);
insert.Parameters.Add("enabled", ColumnType.Bool, true);

int inserted = await insert.ExecuteNonQueryAsync();

Parameterized SQL​

await using CamusCommand insert = connection.CreateCamusCommand("""
INSERT INTO robots (id, name, year, kind, price, enabled)
VALUES (GEN_ID(), @name, @year, @kind, @price, @enabled)
""");

insert.Parameters.Add("@name", ColumnType.String, "R2-D2");
insert.Parameters.Add("@year", ColumnType.Integer64, 1977);
insert.Parameters.Add("@kind", ColumnType.String, "mechanical");
insert.Parameters.Add("@price", ColumnType.Float64, 25.5);
insert.Parameters.Add("@enabled", ColumnType.Bool, true);

int inserted = await insert.ExecuteNonQueryAsync();

RETURNING​

Use ExecuteReaderAsync() or ExecuteScalarAsync() when an INSERT, UPDATE, or DELETE has a RETURNING list:

await using CamusCommand update = connection.CreateCamusCommand("""
UPDATE robots
SET year = @year
WHERE name = @name
RETURNING id, name, year
""");

update.Parameters.Add("@name", ColumnType.String, "R2-D2");
update.Parameters.Add("@year", ColumnType.Integer64, 1977);

await using CamusDataReader returned = await update.ExecuteReaderAsync();
while (await returned.ReadAsync())
{
string id = returned.GetString(0);
long year = returned.GetInt64(2);
}

int affected = returned.RecordsAffected;

ExecuteNonQueryAsync() returns the affected-row count only. The driver tells the server to discard the returned rows, but the server still validates the RETURNING list and checks the required SELECT privilege on the target table.

ExecuteReaderAsync() and ExecuteScalarAsync() receive all returned rows in one reply. On gRPC, a reply larger than the message limit fails with CADB0550 before the commit. For a large INSERT ... SELECT ... RETURNING, use ExecuteStreamReaderAsync() so the query endpoint streams rows.

Query rows​

Use ExecuteReaderAsync() to read result rows:

await using CamusCommand select = connection.CreateSelectCommand(
"SELECT id, name, year FROM robots WHERE year = @year");

select.Parameters.Add("@year", ColumnType.Integer64, 1977);

await using CamusDataReader reader = await select.ExecuteReaderAsync();

while (await reader.ReadAsync())
{
string id = reader.GetString(0);
string name = reader.GetString(1);
long year = reader.GetInt64(2);
}

ExecuteReaderAsync() materializes the result before it returns. For a large SELECT, use ExecuteStreamReaderAsync() so rows are read incrementally from the server's streaming endpoint:

await using CamusCommand select = connection.CreateSelectCommand(
"SELECT id, name, year FROM robots WHERE year > @year");

select.Parameters.Add("@year", ColumnType.Integer64, 1900);

await using CamusDataReader reader =
await select.ExecuteStreamReaderAsync();

while (await reader.ReadAsync())
{
string name = reader.GetString(1);
long year = reader.GetInt64(2);
}

The streaming path uses the same reader API. It does not expose cache metadata, and it cannot transparently replay a serializable conflict after rows have already been delivered. Use the buffered reader when you need automatic retry around a single statement.

The reader exposes the standard typed getters, such as:

  • GetString
  • GetBoolean
  • GetInt16 / GetInt32 / GetInt64
  • GetFloat / GetDouble
  • GetDecimal
  • GetGuid
  • GetDateTime
  • GetFieldValue<T> for DateOnly, DateTimeOffset, byte[], float, Guid, and other provider-supported values
  • IsDBNull

Parameters​

Parameters are input-only. Supported value mappings include:

Camus typeTypical .NET values
ColumnType.Idstring, Guid, CamusObjectIdValue
ColumnType.UuidGuid, canonical UUID string
ColumnType.Stringstring
ColumnType.Integer64short, int, long, other integer-convertible values
ColumnType.Numericdecimal, decimal text string, integer values
ColumnType.Float64double, decimal, other floating-convertible values
ColumnType.Float32float, other floating-convertible values
ColumnType.Boolbool
ColumnType.Bytesbyte[], ReadOnlyMemory<byte>, Memory<byte>, ArraySegment<byte>, IEnumerable<byte>
ColumnType.DateDateOnly, DateTime, DateTimeOffset, ISO date/time string
ColumnType.DateTimeDateTime, DateTimeOffset, ISO date/time string
ColumnType.ArrayIEnumerable of a scalar supported type
ColumnType.Nullnull, DBNull.Value

Examples:

command.Parameters.Add("@id", ColumnType.Id, Guid.NewGuid());
command.Parameters.Add("@ref", ColumnType.Uuid, Guid.NewGuid());
command.Parameters.Add("@count", ColumnType.Integer64, 5);
command.Parameters.Add("@total", ColumnType.Numeric, 123.45m);
command.Parameters.Add("@price", ColumnType.Float64, 19.99);
command.Parameters.Add("@payload", ColumnType.Bytes, new byte[] { 0xDE, 0xAD });
command.Parameters.Add("@day", ColumnType.Date, new DateOnly(2026, 5, 1));
command.Parameters.Add("@happened", ColumnType.DateTime, DateTimeOffset.UtcNow);
command.Parameters.Add("@note", ColumnType.Null, null);

For an array, pass isArray: true. Set the element scalar type explicitly when the array is empty or every current value is NULL.

command.Parameters.Add(
"@tags",
ColumnType.Integer64,
new long[] { 1, 2, 3 },
isArray: true);

command.Parameters.Add(
"@empty_tags",
ColumnType.String,
Array.Empty<string>(),
isArray: true);

The driver normalizes dates and datetimes to UTC before sending them. It stores DATE values at midnight UTC and reads DATETIME values back with DateTimeKind.Utc.

Data types​

The ADO.NET driver covers CamusDB's current scalar types and its array type:

SQL DDL typeDriver typeTypical read/write type
OID, OBJECT_IDColumnType.Idstring, Guid, CamusObjectIdValue
UUID, GUIDColumnType.UuidGuid
STRING, STRING(N)ColumnType.Stringstring
INT64, BIGINT, INT, INTEGER, SMALLINTColumnType.Integer64long, int, short
NUMERIC, DECIMALColumnType.Numericdecimal, exact decimal string
FLOAT64ColumnType.Float64double
FLOAT32, REALColumnType.Float32float
BOOL, BOOLEANColumnType.Boolbool
BYTES, BLOBColumnType.Bytesbyte[]
DATEColumnType.DateDateOnly, DateTime
DATETIME, TIMESTAMPColumnType.DateTimeDateTime, DateTimeOffset
JSONB, JSONColumnType.JsonbJSON text on read; JsonDocument, JsonElement, JsonNode, or typed text on write.
ARRAY(T)ColumnType.Arrayobject?[] on read, IEnumerable on write

Integer declarations share ColumnType.Integer64 and the same wire values; the server enforces the declared column range.

Store UUIDs in a native UUID column, not as text in a STRING. The native type is more compact in memory and on disk, and it compares as a fixed-width value.

NUMERIC is an exact decimal type with fixed precision and scale. A decimal parameter binds as NUMERIC; use ColumnType.Numeric with a string when the value has more significant digits than .NET decimal can hold. Read small values with GetDecimal() or GetFieldValue<decimal>(); read every value without rounding with GetString().

JSON documents​

Use JSONB parameters to store and query JSON documents without serializing them into ordinary string columns. JsonDocument, JsonElement, and JsonNode values infer ColumnType.Jsonb; type raw text explicitly:

using System.Text.Json;

await using CamusCommand command = connection.CreateCamusCommand(
"SELECT payload FROM events WHERE payload @> @filter");
command.Parameters.Add("filter", ColumnType.Jsonb, """{"kind": "login"}""");
await using CamusDataReader reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
JsonElement payload = reader.GetFieldValue<JsonElement>(0);
}

GetString() and GetValue() return the PostgreSQL JSON text form, preserving numeric digits and scale. GetFieldValue<T>() can instead parse a JsonDocument, JsonElement, JsonNode, JsonObject, or JsonArray. CamusJsonb.Format(text) produces the same text form.

A JSON null document reads as the string "null"; SQL NULL reads as DBNull. From 0.17.1, REST requests ask for jsonbAsText to preserve that distinction; an older server that ignores the option cannot distinguish the two over REST. Arrays of JSONB documents are unsupported; bind one JSON array document instead.

Infinity and NaN​

From 0.17.2, the driver reads and binds infinity and NaN on both REST and gRPC. Use double.PositiveInfinity, double.NegativeInfinity, or double.NaN with ColumnType.Float64, and the corresponding float values for Float32. REST encodes them as named strings; the reader returns native .NET floats, including inside float arrays. See float behavior.

Transactions​

CamusDB transactions are exposed through BeginTransactionAsync():

CamusTransaction tx = await connection.BeginTransactionAsync();

await using CamusCommand insert = connection.CreateCamusCommand("""
INSERT INTO robots (id, name, year)
VALUES (GEN_ID(), @name, @year)
""");

insert.Transaction = tx;
insert.Parameters.Add("@name", ColumnType.String, "HAL 9000");
insert.Parameters.Add("@year", ColumnType.Integer64, 1968);

await insert.ExecuteNonQueryAsync();
await tx.CommitAsync();

Use await tx.RollbackAsync() to abort the transaction.

CamusTransactionOptions lets you set the concurrency behavior for an explicit transaction:

OptionValuesDefault
IsolationLevelSerializable, ReadCommittedserver default, Serializable
ModeReadWrite, ReadOnlyReadWrite
LockingPessimistic, Optimisticserver default, Pessimistic
Affinityexact SQL text of a statement with a learned routenull
CamusTransaction tx = await connection.BeginTransactionAsync(
new CamusTransactionOptions
{
IsolationLevel = CamusIsolationLevel.Serializable,
Mode = CamusTransactionMode.ReadWrite,
Locking = CamusLocking.Optimistic,
});

For a read-only serializable snapshot, use the built-in snapshot options:

CamusTransaction snapshot =
await connection.BeginTransactionAsync(CamusTransactionOptions.Snapshot);

Connection-string defaults apply to transactions and autocommit statements:

Endpoint=http://localhost:5095;Database=test;IsolationLevel=Serializable;Locking=Optimistic;TransactionMode=ReadWrite

Precedence is: per-transaction options, then connection-string defaults, then the server default.

With learned routing enabled, BeginTransactionAsync() can defer BEGIN until the first statement. The driver then starts the transaction on that statement's learned endpoint, and every later statement plus commit or rollback remains pinned there. If no route has been learned, it falls back to endpoint rotation.

Use CamusTransactionOptions.Affinity when a later, hotter statement should choose the transaction endpoint. The value must be the exact SQL text whose route should be used. With RoutingMode=Off, BeginTransactionAsync() sends BEGIN immediately as before.

In routed mode, TxnIdPT, TxnIdCounter, and TransactionId are zero until the transaction starts. Use IsStarted when a diagnostic path needs to distinguish a deferred transaction from one that already has a server handle.

Serializable retries​

Serializable is CamusDB's default isolation level. When two serializable read-write transactions conflict, CamusDB aborts one of them, and your application must replay that whole unit of work from the start.

The client package includes a SerializableRetryHelper for this retry contract. It treats these CamusDB error codes as retryable, and no others:

CodeNameMeaning
CADB0502TransactionConflictA lock conflict aborted the transaction.
CADB0504TransactionMustRetryA transient routing, leader change, lock-wait, storage conflict, or temporarily unservable read-range condition exhausted internal retries.
CADB0505TransactionLifetimeExceededA serializable read-write transaction exceeded the server lifetime cap.

The helper intentionally leaves out CADB0509 TransactionFinalizeUnresolved. That code means a commit or rollback has no final answer yet: retry the same finalize on the same transaction, and don't replay the operation from the start.

Use SerializableRetryHelper.IsRetryable(...) when you own the retry loop:

catch (CamusException ex) when (SerializableRetryHelper.IsRetryable(ex))
{
// Replay the whole transaction from the beginning.
}

For single-statement autocommit work, use SerializableRetryHelper.ExecuteAutocommitAsync(...):

await SerializableRetryHelper.ExecuteAutocommitAsync(async ct =>
{
CamusTransaction tx = await connection.BeginTransactionAsync(ct);
try
{
await using CamusCommand update = connection.CreateCamusCommand("""
UPDATE robots SET price = @price WHERE name = @name
""");

update.Transaction = tx;
update.Parameters.Add("@price", ColumnType.Float64, 99.0);
update.Parameters.Add("@name", ColumnType.String, "T-800");

await update.ExecuteNonQueryAsync(ct);
await tx.CommitAsync(ct);
}
catch
{
await tx.RollbackAsync(ct);
throw;
}
}, maxAttempts: 5, cancellationToken);

For a multi-statement explicit transaction, don't retry the failed statement alone. Start a new transaction and run every read and write in the unit again:

const int MaxAttempts = 5;

for (int attempt = 1; ; attempt++)
{
CamusTransaction tx = await connection.BeginTransactionAsync();
try
{
long balance = await ReadBalance(tx, accountId);
if (balance < amount)
throw new InvalidOperationException("Insufficient funds");

await Debit(tx, accountId, balance - amount);
await tx.CommitAsync();
break;
}
catch (CamusException ex) when (SerializableRetryHelper.IsRetryable(ex))
{
await tx.RollbackAsync();
if (attempt >= MaxAttempts)
throw;

await Task.Delay(20 * (1 << attempt));
}
catch
{
await tx.RollbackAsync();
throw;
}
}

The helper's default backoff is a bounded exponential delay with random jitter: min(20 ms * 2^attempt, 400 ms), plus or minus 25 percent.

Prepared statements​

The driver prepares repeated SQL automatically. Once the same SQL shape has run enough times, later executions run as a prepared statement, with no change to your application code.

for (int i = 0; i < 100; i++)
{
await using CamusCommand select = connection.CreateSelectCommand(
"SELECT name FROM robots WHERE year = @year");

select.Parameters.Add("@year", ColumnType.Integer64, 1984);

await using CamusDataReader reader = await select.ExecuteReaderAsync();
while (await reader.ReadAsync())
Console.WriteLine(reader.GetString(0));
}

By default, the first execution runs inline and the second prepares the statement. Tune that policy in the connection string:

CamusConnectionStringBuilder eager = new(
"Endpoint=http://localhost:5095;Database=test;MaxAutoPrepare=512;AutoPrepareMinUsages=1");

CamusConnectionStringBuilder off = new(
"Endpoint=http://localhost:5095;Database=test;MaxAutoPrepare=0");

Call Prepare() or PrepareAsync() when you already know a statement is hot:

await using CamusCommand insert = connection.CreateCamusCommand("""
INSERT INTO robots (id, name, year)
VALUES (GEN_ID(), @name, @year)
""");

await insert.PrepareAsync();

foreach (Robot robot in robots)
{
insert.Parameters.Clear();
insert.Parameters.Add("@name", ColumnType.String, robot.Name);
insert.Parameters.Add("@year", ColumnType.Integer64, robot.Year);

await insert.ExecuteNonQueryAsync();
}

A prepared execution behaves like an inline one, including transactions, isolation, locks, affected-row counts, and the query result cache.

ADO.NET still binds parameters by name. The driver maps each name to the server's positional binding order.

SELECT, INSERT, UPDATE, DELETE, and SHOW can be prepared. The driver runs other statements inline.

If the server reports an unknown handle, the driver prepares the statement again and replays the execution once, transparently.

CamusConnectionStringBuilder.PreparedStatementCount and IsPrepared(sql) are available for diagnostics.

See Prepared Statements for server-side handle scope, handle lifetime over REST and gRPC, and limits.

Vectors​

CamusDB vector search uses a BYTES column that stores tightly packed little-endian float32 values. The driver keeps that layout in one helper:

float[] embedding = await EmbedAsync("robot assembly manual");
byte[] packed = CamusVector.ToBytes(embedding);

command.Parameters.Add("@embedding", ColumnType.Bytes, packed);

Read a vector back with GetVector(...):

float[] embedding = reader.GetVector(reader.GetOrdinal("embedding"));

The dimension is not part of the BYTES type. Enforce it with a check constraint:

await connection.CreateCamusCommand("""
CREATE TABLE documents (
id OID PRIMARY KEY NOT NULL DEFAULT(gen_id()),
body STRING NOT NULL,
embedding BYTES NOT NULL,
CONSTRAINT embedding_768d CHECK (vector_dims(embedding) = 768)
)
""").ExecuteDDLAsync();

Pass the query vector as a parameter and rank with a vector function:

byte[] query = CamusVector.ToBytes(await EmbedAsync("warranty claims"));

await using CamusCommand search = connection.CreateSelectCommand("""
SELECT id, l2_distance(embedding, @query) AS distance
FROM documents
ORDER BY distance ASC
LIMIT 10
""");

search.Parameters.Add("@query", ColumnType.Bytes, query);

See Vector Search for the SQL functions, error codes, and query-planning behavior.

Query result cache​

The CamusDB query result cache is available from plain SQL: put a {cache=...} hint after the table reference. You can also build the hint with CamusCacheHint.

string hint = CamusCacheHint.Build(
"recent_orders",
ttl: TimeSpan.FromSeconds(30),
strict: true);

await using CamusCommand select = connection.CreateSelectCommand(
$"SELECT id, total FROM orders {hint} WHERE status = @status");

select.Parameters.Add("@status", ColumnType.Integer64, 1);

await using CamusDataReader reader = await select.ExecuteReaderAsync();

CamusCacheMetadata? cache = reader.CacheMetadata;
CamusCacheMetadata? lastCache = select.LastCacheMetadata;

CamusCacheMetadata reports the server cache's decision. Its status is Hit, Miss, Bypass, StaleRevalidated, or EvictedBeforePublish.

Evict cache families through the connection:

await connection.EvictCacheAsync("recent_orders");
await connection.EvictAllCacheAsync();

Cache entries belong to the current database. See Query Result Cache for the query shape rules.

Authentication​

CamusDB authentication is off by default. A connection string without credentials sends no Authorization header.

Against a server with authentication enabled, add User and Password:

CamusConnectionStringBuilder builder = new(
"Endpoint=https://db.example.com:7141;Database=app;User=myapp;Password=app-secret");

The driver exchanges the password once for a short-lived bearer token, using the REST /login endpoint on REST connections and the CamusAuth service on gRPC connections.

Statements then send the token, not the password. The driver caches the token per set of credentials and renews it based on the expiry the server reports. If a cached token is rejected because it expired or was rotated out, the driver discards it, logs in again, and replays the statement once. Permission errors are not retried.

You can also log in explicitly, for example when the password comes from a secrets manager:

await using CamusConnection connection = new(
new CamusConnectionStringBuilder("Endpoint=https://db.example.com:7141;Database=app"));

await connection.OpenAsync();
string token = await connection.LoginAsync("myapp", passwordFromSecretManager);
await connection.LogoutAsync();

AccessToken=... uses a token obtained elsewhere. The driver doesn't renew it, so the server returns CADB0516 once the token expires or is revoked.

For authenticated deployments beyond loopback, use an https:// address. The driver refuses to send Password or AccessToken credentials to a remote plaintext endpoint unless the connection string sets AllowInsecureCredentials=true. See Authentication And Authorization for server setup, grants, and TLS behavior.

Connection string​

A CamusConnection connection string must include:

  • Endpoint: the base CamusDB endpoint
  • Database: the database name to use
using CamusDB.Client;

CamusConnectionStringBuilder builder =
new("Endpoint=http://localhost:5095;Database=test");

await using CamusConnection connection = new(builder);
await connection.OpenAsync();

Supported keys:

KeyRequiredDescription
EndpointYesBase URL for the CamusDB node. Use the REST port by default, or the gRPC port when Protocol=grpc.
DatabaseYesDatabase name sent on requests.
TimeoutNoRequest timeout in seconds. Defaults to 10.
ProtocolNoWire protocol: rest by default, or grpc.
UserNoUser to authenticate as. Aliases: UserId, Uid, Username.
PasswordNoPassword for User. Alias: Pwd.
AccessTokenNoBearer token obtained elsewhere. Used directly instead of logging in.
TokenLifetimeNoFallback seconds to reuse a token if the server does not report expiry. Defaults to 600.
MaxAutoPrepareNoMaximum statements the driver keeps prepared. Defaults to 128; 0 disables automatic preparation.
AutoPrepareMinUsagesNoExecutions of the same SQL before the driver prepares it. Defaults to 2.
IsolationLevelNoDefault isolation for transactions and autocommit statements: Serializable or ReadCommitted. Server default is Serializable.
TransactionModeNoDefault transaction mode: ReadWrite or ReadOnly.
LockingNoDefault locking mode: Pessimistic or Optimistic.
ChannelPoolSizeNogRPC only: long-lived BatchExecute streams per endpoint. Defaults to 2.
CoalescingThresholdNogRPC only: operations a batch drain tries to collect before it stops waiting. Defaults to 10; 1 disables coalescing.
CoalescingDelayNogRPC only: milliseconds to wait for a larger batch. Defaults to 0 (no wait). Operations that are already waiting still share a frame when RequestFrames is on. Set 1 or 2 when many concurrent callers on one connection send bursts.
RequestFramesNogRPC only: whether the client may pack waiting batch operations into negotiated stream frames. Defaults to true; set false as a compatibility or measurement switch.
TraceQueryTextNoAdds SQL text to driver spans as db.query.text. Defaults to false, because SQL literals can contain sensitive values.
BackupEndpointNoHTTP endpoint for the backup admin API. Defaults to Endpoint; required when Protocol=grpc.
BackupTimeoutNoBackup admin timeout in seconds. Defaults to 300.
AllowInsecureCredentialsNoSet true to allow credentials over a non-loopback plaintext endpoint. Use only when another trusted layer protects that hop.
RoutingModeNoLearned statement routing: Auto, Learned, or Off. Defaults to Auto.
RoutingNodesNoTrust map from server node identities to addresses already listed in Endpoint. Quote the value because it contains =.
RoutingMaxHintAgeNoMaximum age, in milliseconds, for a learned route. Defaults to 5000.

Endpoint can also be a comma-separated pool:

Endpoint=http://localhost:5095,http://localhost:5096,http://localhost:5097;Database=test

The client uses each endpoint in turn. When a transport request cannot reach an endpoint, the driver sets that endpoint aside for 30 seconds and skips it meanwhile. The rotation and health state are shared by every connection that uses the same Endpoint value.

Connection string keys are case-insensitive and trimmed. Repeating a key is an error. Quote a value with single or double quotes when it contains a semicolon or surrounding spaces; double the quote character to include the quote itself.

CamusConnectionStringBuilder.ToString() returns the original connection string, including credentials. Use ToRedactedString() or CamusConnectionStringBuilder.Redact(...) for logs. They mask Password, Pwd, and AccessToken. A CamusConnection keeps the connection string it was built from; assigning a different value later throws NotSupportedException.

Transport​

The driver can use the REST endpoint or the gRPC endpoint with the same CamusCommand and CamusTransaction APIs:

CamusConnectionStringBuilder rest = new(
"Endpoint=http://localhost:5095;Database=test");

CamusConnectionStringBuilder grpc = new(
"Endpoint=http://localhost:5096;Database=test;Protocol=grpc");

REST and gRPC listen on separate server ports. When you set Protocol=grpc, point Endpoint at the gRPC port. Use http:// for local plaintext HTTP/2, or https:// for a TLS-terminated deployment.

Under gRPC, queries, non-queries, and transaction lifecycle messages are multiplexed over a small pool of long-lived BatchExecute streams. Autocommit statements fan out across the pool. A transaction is pinned to one stream so the server observes its operations in order. Tune the pool with ChannelPoolSize, CoalescingThreshold, CoalescingDelay, and RequestFrames only when a measured workload needs it. Stream frames are negotiated per stream and are a transport optimization only: they add no atomicity and keep the same per-operation result contract.

Learned routing​

A CamusDB server can attach advisory routing metadata to a successful SQL response. With learned routing enabled, the .NET driver uses that metadata to send later executions of the same statement directly to the node that leads the statement's data. This is a latency optimization only. The server still executes correctly wherever the request lands.

CamusConnectionStringBuilder builder = new(
"Endpoint=http://a:5095,http://b:5095,http://c:5095;Database=test;RoutingMode=Learned;" +
"RoutingNodes='camus-a:7070=http://a:5095,camus-b:7070=http://b:5095,camus-c:7070=http://c:5095'");

RoutingNodes is the routing authority. The server advertises an opaque node identity. The driver uses it only when the connection string maps that identity to an address that is also present in the Endpoint pool. It never dials an address supplied by a server response.

RoutingMode=Auto is the default. It negotiates routing advice only when RoutingNodes names at least two distinct endpoints. RoutingMode=Learned negotiates when at least one identity is mapped. RoutingMode=Off disables the feature and keeps endpoint rotation only.

The driver learns routes per database, exact SQL text, and statement kind. The TTL is the smaller of the server's maxAgeMs and RoutingMaxHintAge. The cache is bounded, and stale advice only costs an extra forwarding hop until it expires or a newer response replaces it. Parameterized and prepared workloads benefit the most because they reuse the same SQL text.

CamusCommand.LastRoutingAdvice exposes the most recent advice for diagnostics. The eligible statements and the wire contract are described in SQL Routing Advice.

Ping​

Use a ping command to verify connectivity:

await using CamusCommand ping = connection.CreatePingCommand();
int result = await ping.ExecuteNonQueryAsync();

Node resource stats​

The client can read operational statistics over REST or gRPC. Call it without an argument to ask the next endpoint in the rotation, or pass one of the endpoints from the connection string:

CamusNodeStats current = await connection.GetNodeStatsAsync();
CamusNodeStats node = await connection.GetNodeStatsAsync("http://db2.example.com:5095");

The endpoint must match an entry in Endpoint exactly. Any other value throws ArgumentException, so the client never sends its credentials to an unconfigured address.

The result covers disk volumes, CPU, memory, node identity, and uptime. With authentication enabled, the connection must use a superuser or an account with the global MONITOR privilege. See Node resource stats.

Database administration​

Opening a connection does not create its database. Create or drop databases explicitly:

await connection.CreateDatabaseAsync(ifNotExists: true);
await connection.CreateDatabaseAsync("otherdb", ifNotExists: true);

await connection.DropDatabaseAsync("old_test_db");

CreateDatabaseAsync() and DropDatabaseAsync() both act on the database of the connection string. An explicit name changes that target. Database creation retries its small set of transient schema-allocation conflicts internally.

For a copy-on-write database branch, use the branch helpers:

await connection.CreateBranchDatabaseAsync(
branchName: "factory_test",
sourceDatabaseName: "factory",
ifNotExists: true);

IReadOnlyList<CamusBranchRow> branches =
await connection.ShowBranchesAsync("factory");

IReadOnlyList<CamusBranchRow> ancestors =
await connection.ShowAncestorsAsync("factory_test");

See Database Branching for how the SQL behind these helpers behaves.

Backups​

connection.Backups exposes the online backup administration API. You can take full, incremental, or coordinated backups, inspect the backup catalog, validate a restore chain, and run retention:

CamusBackupInfo full = await connection.Backups.TakeFullBackupAsync();
CamusBackupInfo incremental =
await connection.Backups.TakeIncrementalBackupAsync(full.BackupId);

IReadOnlyList<CamusBackupInfo> chain =
await connection.Backups.GetChainAsync(incremental.BackupId);

IReadOnlyList<CamusBackupInfo> catalog =
await connection.Backups.ListBackupsAsync();

CamusBackupGcResult preview =
await connection.Backups.PreviewGarbageCollectionAsync();

CamusBackupGcResult applied =
await connection.Backups.CollectGarbageAsync();

Backups are node-wide. They are not scoped to the connection's Database value. The server must have kahuna.backup_dir configured before these calls can succeed.

The backup admin API is REST-only. If the main connection uses Protocol=grpc, set BackupEndpoint to the HTTP endpoint and use BackupTimeout for the longer timeout a full backup may need:

Endpoint=http://localhost:5096;Database=test;Protocol=grpc;BackupEndpoint=http://localhost:5095;BackupTimeout=300

See Backup And Restore for the server-side backup model.

OpenTelemetry​

CamusDB.Client emits traces and metrics through the .NET ActivitySource and Meter APIs. The driver itself has no dependency on the OpenTelemetry SDK. Both sources use the name CamusDB.Client, exposed as CamusTelemetry.ActivitySourceName and CamusTelemetry.MeterName.

Register the sources with the OpenTelemetry SDK and then configure the exporter that your application uses:

using CamusDB.Client;

builder.Services.AddOpenTelemetry()
.WithTracing(tracing =>
tracing.AddSource(CamusTelemetry.ActivitySourceName))
.WithMetrics(metrics =>
metrics.AddMeter(CamusTelemetry.MeterName));

The optional CamusDB.OpenTelemetry package provides convenience extension methods for the same registration:

dotnet add package CamusDB.OpenTelemetry
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;

builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing.AddCamusDBInstrumentation())
.WithMetrics(metrics => metrics.AddCamusDBInstrumentation());

The package depends only on OpenTelemetry.Api. The application still chooses and configures its OpenTelemetry SDK and exporters.

Traces​

The driver creates an ActivityKind.Client span for:

  • Each command execution, including queries, writes, and DDL.
  • Each typed insert through CamusInsertCommand.
  • BEGIN, COMMIT, and ROLLBACK. One finalize span covers all retries after CADB0509 TransactionFinalizeUnresolved.
  • Each pipeline exchange, as one BATCH span.

Dapper and EF Core execute through the same command APIs, so their operations receive driver spans too. A span is a child of the current application activity. The HTTP or gRPC transport spans, when enabled, are children of the driver span.

A span name has the form {operation} {target}, such as SELECT shop. The target is the table for a typed insert and the database for other operations. The driver adds these attributes where they apply:

AttributeValue
db.system.namecamusdb
db.namespaceThe database from the connection string.
db.operation.nameThe SQL verb, or BEGIN, COMMIT, ROLLBACK, or BATCH.
db.collection.nameThe table of a typed insert.
db.operation.batch.sizeThe number of operations in a pipeline exchange.
server.address, server.portThe endpoint that received the operation after routing.
camusdb.transportrest or grpc.
db.query.textThe SQL text, only when TraceQueryText=true.
error.typeThe CADBxxxx code, or the exception type for a non-CamusDB failure.
db.response.status_codeThe CADBxxxx code of a failed CamusDB operation.

SQL text is off by default because a literal in a statement can contain sensitive data. Enable it for one connection with:

Endpoint=http://localhost:5095;Database=app;TraceQueryText=true

The driver never records parameter values. A retry made while a driver span is current also adds a camusdb.retry event with camusdb.retry.kind and error.type.

A buffered query span ends when the command returns. A streaming query span remains open until the reader reaches the end, fails while reading, or is closed. This makes its duration include the time that the application spends consuming the stream.

Metrics​

The CamusDB.Client meter emits the database operation duration and metrics for retries, endpoint health, and automatic prepared statements:

MetricTypeMeaning
db.client.operation.durationHistogram, secondsDuration of the same operations that produce spans. It includes database, operation, endpoint, and error attributes, but never SQL text.
camusdb.client.operation.retriesCounterOperations sent again. camusdb.retry.kind is finalize, autocommit, token_renewal, or create_database.
camusdb.client.endpoint.quarantinesCounterTimes a transport failure caused the driver to set an endpoint aside for 30 seconds.
camusdb.client.endpoint.quarantinedGauge1 while an endpoint is set aside and 0 while it can receive traffic.
camusdb.client.prepared_statement.lookupsCounterPrepared-statement lookups by result: hit, registered, or miss.
camusdb.client.prepared_statement.registrationsCounterPreparation attempts by result: success, refused, or unsupported.
camusdb.client.prepared_statement.evictionsCounterPrepared statements released to make room for more recently used statements.
camusdb.client.prepared_statement.countUp-down counterStatements currently held in the driver's prepared-statement cache.

The operation metrics use low-cardinality attributes such as the database, operation name, endpoint, and error code. Prepared-statement metrics use db.namespace and camusdb.prepared_statement.result where applicable.

Use these signals together:

  • Rising endpoint quarantines identify transport failures and endpoint churn.
  • Retry counts separate transaction conflicts, unresolved finalization, token renewal, and database-creation retries.
  • A prepared-statement count that remains at MaxAutoPrepare while evictions grow indicates that the cache is too small for the active statement set.

When no listener enables the activity source or instruments, the driver creates no spans, tags, or timestamps for an operation. OpenAsync() opens no physical database connection, so the driver does not publish db.client.connection.* metrics. If the application also enables OpenTelemetry.Instrumentation.EntityFrameworkCore, an EF Core query produces both an EF Core span and a CamusDB driver span.

ADO.NET notes​

  • The provider can use REST/JSON or gRPC with the same ADO.NET surface. Select gRPC with Protocol=grpc and point Endpoint at the gRPC listener.
  • Cancel() is cooperative through cancellation tokens.
  • Concurrent reads can share a connection session.
  • Transaction-scoped commands are pinned to the transaction endpoint, which is important when the connection string contains multiple endpoints.