EvaluatedApplications.HoloDb.Client
1.5.3
dotnet add package EvaluatedApplications.HoloDb.Client --version 1.5.3
NuGet\Install-Package EvaluatedApplications.HoloDb.Client -Version 1.5.3
<PackageReference Include="EvaluatedApplications.HoloDb.Client" Version="1.5.3" />
<PackageVersion Include="EvaluatedApplications.HoloDb.Client" Version="1.5.3" />
<PackageReference Include="EvaluatedApplications.HoloDb.Client" />
paket add EvaluatedApplications.HoloDb.Client --version 1.5.3
#r "nuget: EvaluatedApplications.HoloDb.Client, 1.5.3"
#:package EvaluatedApplications.HoloDb.Client@1.5.3
#addin nuget:?package=EvaluatedApplications.HoloDb.Client&version=1.5.3
#tool nuget:?package=EvaluatedApplications.HoloDb.Client&version=1.5.3
HoloDb.Client
A thin TCP+TLS .NET client for a remote HoloDb server. It has the same Execute / ExecuteAsync / BulkLoadAsync shape as the in-process HoloDbService and returns the same QueryResult type, so moving from embedded to networked changes only how you get the handle. Results stay columnar over the wire (typed arrays, no JSON) unless they hold SQL NULL (see NULL values). One connection = one session; requests serialize.
Quick start
using HoloDb.Client;
// Connect with TLS, auth token, and pinned certificate
await using var db = await HoloDbClient.ConnectAsync("your-host", 5432, new HoloDbClientOptions
{
AuthToken = "s3cret",
PinnedThumbprint = "ABCD...", // Server prints this on startup
});
// Execute a query — get columnar results (no boxing).
// id and name are declared NOT NULL here; a result that touches a NULL cell is row-form (see NULL values).
var result = await db.ExecuteAsync("SELECT id, name FROM users WHERE age > 18");
for (var i = 0; i < result.RowCount; i++)
{
var id = result.GetLong(i, 0);
var name = result.GetText(i, 1);
Console.WriteLine($"User {id}: {name}");
}
// Bulk-load data (column-native, no SQL parsing)
await db.BulkLoadAsync("users", new[] { "id", "age", "name" },
new Array[] {
new long[] { 2, 3 },
new long[] { 25, 41 },
new[] { "Ben", "Cara" }
},
count: 2);
// Get server stats
var stats = await db.GetStatsAsync();
Console.WriteLine($"Uptime: {stats["uptime_seconds"]}, Queries: {stats["queries"]}");
// Ping the server (throws on drop, returns true if alive)
await db.PingAsync();
Transactions are per-session — send SQL BEGIN/COMMIT/ROLLBACK as plain text. For pooled connections, bounded timeouts, or auto-reconnect-on-drop, see the Resilience & Pooling section.
Table of Contents
- Core Client
- Pooling
- Resilience & Retry
- Exceptions
- Connection Lifecycle & Gotchas
- Thread Safety & Concurrency
- Server Discovery & Deployment
Core Client
HoloDbClientOptions
Connection configuration passed to ConnectAsync. All properties are optional; default is to enable TLS with no cert pinning (so you must set AllowUntrustedCertificate = true for dev, or pin a thumbprint for production).
| Property | Purpose |
|---|---|
AuthToken : string? |
Shared secret presented during TLS handshake. Server-side set via --token. Sent over TLS, never unencrypted. Defaults to empty string if not set. |
UseTls : bool (default true) |
Enable TLS encryption. Must match the server's config. |
TlsHost : string? |
SNI host name for TLS validation. Defaults to the host passed to ConnectAsync. Set this if your DNS CNAME differs from the cert's CN. |
PinnedThumbprint : string? |
Pin the server's certificate by thumbprint (recommended for self-hosted/self-signed servers). When set, the connection accepts the cert iff its SHA1 thumbprint matches exactly, ignoring CA chain. Overrides AllowUntrustedCertificate. Server prints its own thumbprint on startup. |
AllowUntrustedCertificate : bool |
Dev-only: accept any certificate (no MITM protection). Ignored if PinnedThumbprint is set. Never use in production. |
Example:
var options = new HoloDbClientOptions
{
AuthToken = "my-secret",
UseTls = true,
TlsHost = "db.example.com", // Custom SNI name
PinnedThumbprint = "1A2B3C...", // Pin server cert
AllowUntrustedCertificate = false
};
await using var db = await HoloDbClient.ConnectAsync("db.internal", 5432, options);
HoloDbClient
The core stateful connection. One instance = one TCP+TLS session. Requests serialize through an internal mutex (no concurrent requests on one client). Transactions (BEGIN/COMMIT/ROLLBACK) are scoped to this session; a dropped connection loses mid-transaction state.
Connecting
static Task<HoloDbClient> ConnectAsync(string host, int port, CancellationToken ct = default)
Connect with default options (TLS on, no pinning). Requires AllowUntrustedCertificate = true for self-signed certs, or set a PinnedThumbprint in the next overload.
await using var db = await HoloDbClient.ConnectAsync("localhost", 5432);
static Task<HoloDbClient> ConnectAsync(string host, int port, HoloDbClientOptions options, CancellationToken ct = default)
Connect with explicit options (auth token, pinning, SNI, etc).
var opts = new HoloDbClientOptions { AuthToken = "secret", PinnedThumbprint = "ABC..." };
await using var db = await HoloDbClient.ConnectAsync("db.example.com", 5432, opts);
static Task<HoloDbClient> ConnectAsync(string host, int port, TimeSpan connectTimeout, CancellationToken ct = default)
Connect with a bounded timeout (TLS + handshake must complete within connectTimeout). Throws TimeoutException if deadline is missed, or OperationCanceledException if ct is cancelled.
try
{
await using var db = await HoloDbClient.ConnectAsync(
"db.example.com", 5432,
TimeSpan.FromSeconds(5));
}
catch (TimeoutException) { Console.WriteLine("Connect took too long"); }
catch (OperationCanceledException) { Console.WriteLine("Caller cancelled"); }
static Task<HoloDbClient> ConnectAsync(string host, int port, HoloDbClientOptions options, TimeSpan connectTimeout, CancellationToken ct = default)
Connect with both options and a timeout.
var opts = new HoloDbClientOptions { AuthToken = "secret" };
await using var db = await HoloDbClient.ConnectAsync(
"db.example.com", 5432, opts,
TimeSpan.FromSeconds(5));
Properties & Info
string ServerInfo { get; }
Handshake info from the server (usually version/capabilities string). Set at connect time, does not change. Read after a successful connect.
var db = await HoloDbClient.ConnectAsync("localhost", 5432);
Console.WriteLine($"Connected to: {db.ServerInfo}");
Querying
Task<QueryResult> ExecuteAsync(string sql, CancellationToken ct = default)
Execute a SQL statement asynchronously. Returns a QueryResult: columnar typed arrays, or row-form when the result involves a NULL (see NULL values). Throws HoloRemoteException on app-level errors (bad SQL, auth failure) and HoloProtocolException on connection failure.
var result = await db.ExecuteAsync(
"SELECT id, name FROM users WHERE id > 0"); // id and name declared NOT NULL
Console.WriteLine($"Rows: {result.RowCount}");
for (var i = 0; i < result.RowCount; i++)
{
var id = result.GetLong(i, 0);
var name = result.GetText(i, 1);
Console.WriteLine($" {id}: {name}");
}
QueryResult Execute(string sql)
Synchronous wrapper over ExecuteAsync (blocking). Avoid on UI threads. Prefer ExecuteAsync in async code.
var result = db.Execute("SELECT COUNT(*) FROM users");
Console.WriteLine($"Users: {result.GetValue(0, 0)}"); // aggregates are row-form: read with GetValue
There is no parameterised Execute(sql, args) on the client (HoloDbService has one; this client does not). Build the SQL text yourself; see Sending values.
Bulk Loading
Task<int> BulkLoadAsync(string table, IReadOnlyList<string> cols, IReadOnlyList<Array> data, int count, CancellationToken ct = default)
Column-native bulk insert (no SQL parsing on either side). Each element of data is a typed array for one column (long[], double[] or string[]). count is the number of rows. Returns number of rows inserted.
The arrays are dense: there is no NULL in a bulk load. A null element of a string[] is sent as empty text (""), so do not rely on it for NULL. To load rows that contain NULL, send them as INSERT text with ExecuteAsync, one statement per batch of rows (VALUES (..),(..) is accepted).
var rowsInserted = await db.BulkLoadAsync(
table: "products",
cols: new[] { "id", "name", "price" },
data: new Array[]
{
new long[] { 1, 2, 3 }, // id column
new[] { "Widget", "Gadget", "Doohickey" }, // name column
new double[] { 9.99, 19.99, 14.99 } // price column
},
count: 3);
Console.WriteLine($"Inserted {rowsInserted} rows");
Server Stats
Task<IReadOnlyDictionary<string, long>> GetStatsAsync(CancellationToken ct = default)
Fetch the server's counters. The keys are defined by the server: uptime_seconds, connections_opened, connections_active, sessions, queries, commits, rollbacks, txn_aborts, bulk_loads, auth_failures, version_rejects, wal_bytes. Read a key that is not in the dictionary and you get KeyNotFoundException.
var stats = await db.GetStatsAsync();
Console.WriteLine($"Connections: {stats["connections_active"]}");
Console.WriteLine($"Queries: {stats["queries"]}");
Console.WriteLine($"WAL size: {stats["wal_bytes"]}");
Health Check
Task<bool> PingAsync(CancellationToken ct = default)
Send a keep-alive Ping frame. Returns true if the server replies with Pong (normal case). Throws HoloProtocolException if the connection is dropped before a reply arrives (matching ExecuteAsync, BulkLoadAsync, GetStatsAsync — all fail the same way). Returns false only if a live connection sends a non-Pong reply (not seen in practice today).
using HoloDb.Protocol;
try
{
var alive = await db.PingAsync();
if (alive) Console.WriteLine("Server is up");
}
catch (HoloProtocolException ex)
{
Console.WriteLine($"Connection dropped: {ex.Message}");
// Reconnect here if desired, or use ResilientHoloDbClient
}
Cleanup
ValueTask DisposeAsync()
Closes the TLS stream and TCP socket. Called automatically by await using. Once disposed, all methods throw ObjectDisposedException.
await using (var db = await HoloDbClient.ConnectAsync("localhost", 5432))
{
// ... use db ...
} // DisposeAsync() called here
NULL values
SQL NULL reaches you as DBNull.Value.
- In
Rows, a NULL cell isDBNull.Value:result.Rows[0]["age"]. GetValue(row, col)returnsDBNull.Valuefor it, andIsNull(row, col)returnstrue.- A result that holds a NULL is row-form:
IsColumnarisfalse. A statement is row-form when it references a column that holds a NULL anywhere in its table, including for rows that have no NULL themselves. GetLong,GetDoubleandGetTextread the typed arrays of a columnar result. On a row-form result they throwNullReferenceException. For any query that can touch a NULL, read withGetValueandIsNull; both work on either shape.
var result = await db.ExecuteAsync("SELECT id, name, age FROM users ORDER BY id");
for (var i = 0; i < result.RowCount; i++)
{
var id = (long)result.GetValue(i, 0);
var name = result.IsNull(i, 1) ? "(no name)" : (string)result.GetValue(i, 1);
var age = result.IsNull(i, 2) ? (long?)null : (long)result.GetValue(i, 2);
Console.WriteLine($"{id}: {name}, age {age?.ToString() ?? "unknown"}");
}
Sending NULL
Write NULL as the literal NULL in the SQL text. The column must be declared nullable (age INT NULL). A
PRIMARY KEY column, or a column declared without NULL, refuses it: the server answers with a
HoloRemoteException such as column 'id' is NOT NULL.
await db.ExecuteAsync("CREATE TABLE users (id INT PRIMARY KEY, name TEXT NULL, age INT NULL)");
await db.ExecuteAsync("INSERT INTO users VALUES (1, 'Ann', 30)");
await db.ExecuteAsync("INSERT INTO users VALUES (2, NULL, NULL)");
await db.ExecuteAsync("UPDATE users SET age = NULL WHERE id = 1");
var unknownAge = await db.ExecuteAsync("SELECT id FROM users WHERE age IS NULL");
Test for NULL with IS NULL / IS NOT NULL. A comparison against the literal NULL (age = NULL) is refused by name.
Sending values
ExecuteAsync takes SQL text only. There is no parameterised overload on the client. To bind values, call the
public static HoloDb.Database.Bind(sql, args) from the HoloDb package, which this package depends on. It replaces
each ? in order with a literal: text is quoted with its single quotes doubled, bool becomes 1 or 0, and
null or DBNull.Value becomes NULL.
var insert = HoloDb.Database.Bind("INSERT INTO users VALUES (?, ?, ?)", 3L, "O'Brien", null);
await db.ExecuteAsync(insert);
// A single null argument must be cast: a bare null is taken as the whole argument array and throws.
var clear = HoloDb.Database.Bind("UPDATE users SET age = ? WHERE id = ?", (object?)null, 3L);
await db.ExecuteAsync(clear);
Pooling
HoloDbClientPool
Owns a fixed-size set of pre-connected HoloDbClient instances and rents them out exclusively — one client per concurrent worker, with no multiplexing. Useful to avoid hand-rolling "one client per worker" bookkeeping; does not add reconnect/retry (that is the job of ResilientHoloDbClient).
Sizing Guidelines
Each client is a single-session, request-serialized connection — no multiplexing or contention inside it. Size the pool to your expected concurrent worker count, not by HikariCP rules:
- If you have 8 worker threads, size the pool to 8.
- Check server limits (HoloDb default: 100 open connections) and stay below.
- Start small and measure: 4–16 typical. Watch lease-wait latency and idle slots.
- Undersized → workers queue for leases → latency climbs.
- Oversized → wastes memory/file descriptors, no throughput gain (each query still serializes through its client's mutex).
Connecting
static Task<HoloDbClientPool> ConnectAsync(string host, int port, int size, HoloDbClientOptions? options = null, CancellationToken ct = default)
Connect size clients up front, all with the same options. Throws if any connection fails; all connected clients so far are disposed. size must be positive.
// Connect 8 clients, reusing them across workers
await using var pool = await HoloDbClientPool.ConnectAsync(
"db.example.com", 5432,
size: 8,
options: new HoloDbClientOptions { AuthToken = "secret" });
// ... do work, then dispose all 8
Properties
int Size { get; }
Number of clients in the pool (fixed at construction).
Console.WriteLine($"Pool size: {pool.Size}");
Renting
Task<Lease> RentAsync(CancellationToken ct = default)
Rent an exclusive client. Blocks until one is free. Dispose the returned Lease (not the client itself) to return it to the pool.
using (var lease = await pool.RentAsync())
{
var result = await lease.Client.ExecuteAsync("SELECT COUNT(*) FROM users");
Console.WriteLine($"User count: {result.GetValue(0, 0)}");
} // Lease disposed here; client returned to pool
Throws ObjectDisposedException if the pool has been disposed.
Cleanup
ValueTask DisposeAsync()
Closes all clients in the pool. After disposal, RentAsync throws ObjectDisposedException.
await pool.DisposeAsync();
Lease
A lightweight struct wrapping a rented client. Dispose to return it to the pool (does not close the underlying connection).
Properties
HoloDbClient Client { get; }
The rented client instance. Valid until the lease is disposed.
using (var lease = await pool.RentAsync())
{
await lease.Client.ExecuteAsync("INSERT INTO log VALUES (1, 'msg')");
}
Resilience & Retry
ResilientHoloDbClient
An opt-in decorator around HoloDbClient that adds reconnect-and-retry on transient transport failures. HoloDbClient itself is reconnect-free by design; use this when you want auto-recovery instead of handling reconnects yourself.
Key invariant: Reconnects create a brand-new session (fresh TCP+TLS handshake). Any mid-transaction state in the old session is lost. Only retry idempotent/self-contained operations — never ones assuming transaction state survived the reconnect.
Connecting
static Task<ResilientHoloDbClient> ConnectAsync(string host, int port, HoloDbClientOptions? options = null, int maxAttempts = 3, TimeSpan? retryDelay = null, TimeSpan? maxRetryDelay = null, int? breakerFailureThreshold = null, TimeSpan? breakerCooldown = null, CancellationToken ct = default)
Connect with opt-in retry/backoff/circuit-breaker settings. All parameters except the first three are optional and additive.
| Parameter | Purpose | Default |
|---|---|---|
maxAttempts |
Total tries per call (1 initial + 2 retries = 3 total). Must be ≥ 1. | 3 |
retryDelay |
Base delay for exponential backoff between attempts. See Backoff Tuning. | 200 ms |
maxRetryDelay |
Cap on backed-off delay. Grows exponentially up to this, then clamps. | 5 seconds |
breakerFailureThreshold |
Circuit breaker threshold: after this many consecutive whole calls fail with transient errors, the breaker opens. null = disabled (previous behavior). |
null (off) |
breakerCooldown |
How long the breaker stays open before admitting a probe. Only used if threshold is set. | 30 seconds |
// Bare resilience: retry up to 3 times with exponential backoff
await using var resilient = await ResilientHoloDbClient.ConnectAsync(
"db.example.com", 5432);
// With circuit breaker: open after 5 consecutive call failures, cooldown 30s
await using var resilient = await ResilientHoloDbClient.ConnectAsync(
"db.example.com", 5432,
maxAttempts: 3,
breakerFailureThreshold: 5,
breakerCooldown: TimeSpan.FromSeconds(30));
// Custom backoff: 100ms base, 10s max
await using var resilient = await ResilientHoloDbClient.ConnectAsync(
"db.example.com", 5432,
retryDelay: TimeSpan.FromMilliseconds(100),
maxRetryDelay: TimeSpan.FromSeconds(10));
Properties
string ServerInfo { get; }
Handshake info from the current session. Changes when a reconnect happens.
Console.WriteLine($"Server: {resilient.ServerInfo}");
Operations
All methods mirror HoloDbClient's surface, plus an optional operationTimeout parameter (deadline for the whole call, not one attempt — see Gotchas for composition rules).
Task<QueryResult> ExecuteAsync(string sql, TimeSpan? operationTimeout = null, CancellationToken ct = default)
Execute SQL with auto-retry on transient failures.
using HoloDb.Protocol;
try
{
var result = await resilient.ExecuteAsync(
"SELECT COUNT(*) FROM users",
operationTimeout: TimeSpan.FromSeconds(10)); // Whole call must finish in 10s
Console.WriteLine($"Rows: {result.RowCount}");
}
catch (HoloRemoteException ex)
{
// App-level error (bad SQL, auth). Not retried.
Console.WriteLine($"Query failed: {ex.Message}");
}
catch (TimeoutException)
{
// operationTimeout deadline exceeded
Console.WriteLine("Operation took too long");
}
catch (CircuitBreakerOpenException ex)
{
// Breaker is open; retry after ex.RetryAfter
Console.WriteLine($"Breaker open, retry after {ex.RetryAfter}");
}
QueryResult Execute(string sql, TimeSpan? operationTimeout = null)
Synchronous wrapper (blocking). Prefer ExecuteAsync.
var result = resilient.Execute("SELECT COUNT(*) FROM users");
Task<int> BulkLoadAsync(string table, IReadOnlyList<string> cols, IReadOnlyList<Array> data, int count, TimeSpan? operationTimeout = null, CancellationToken ct = default)
Bulk insert with auto-retry.
var inserted = await resilient.BulkLoadAsync(
"users",
new[] { "id", "name" },
new Array[] { new long[] { 1 }, new[] { "Alice" } },
count: 1,
operationTimeout: TimeSpan.FromSeconds(5));
Task<IReadOnlyDictionary<string, long>> GetStatsAsync(TimeSpan? operationTimeout = null, CancellationToken ct = default)
Fetch server stats with auto-retry.
var stats = await resilient.GetStatsAsync(
operationTimeout: TimeSpan.FromSeconds(5));
Task<bool> PingAsync(TimeSpan? operationTimeout = null, CancellationToken ct = default)
Send keep-alive with auto-retry. Throws HoloProtocolException on drop (retried), or CircuitBreakerOpenException if the breaker is open.
try
{
await resilient.PingAsync();
Console.WriteLine("Ping succeeded");
}
catch (CircuitBreakerOpenException)
{
Console.WriteLine("Server is down (breaker open)");
}
Cleanup
ValueTask DisposeAsync()
Closes the current session and cleanup resources.
await resilient.DisposeAsync();
Circuit Breaker
Layered on top of retry/backoff. Disabled by default (breakerFailureThreshold = null). Opt in to prevent hammering a down server.
State machine:
- Closed (normal): calls go through with normal retry/backoff.
- Open: every call fails fast with
CircuitBreakerOpenException(no connection attempted) until cooldown elapses. - HalfOpen: cooldown expired; exactly one call admitted as a probe. Success → Closed + reset counters. Transient failure → re-Open + restart cooldown.
Opening rule: After breakerFailureThreshold consecutive whole calls (not individual retry attempts) end in a transient failure, the breaker opens.
Transient failures (retried by default, counted by breaker):
IOException(network/stream errors)SocketException(connection lost)ObjectDisposedException(connection disposed mid-call)HoloProtocolException(framing error)
Application-level errors (never retried, treated as success by breaker):
HoloRemoteException(bad SQL, auth failure, etc) — connection is alive, no breaker penalty.CircuitBreakerOpenException— breaker only (not a transient network error).
Cancellation — neither a caller-cancelled ct nor an expired operationTimeout count as pass/fail for the breaker; they're "the caller's own budget", not a server verdict.
Example:
// Open breaker after 3 consecutive call failures, cooldown 10s
await using var resilient = await ResilientHoloDbClient.ConnectAsync(
"db.example.com", 5432,
breakerFailureThreshold: 3,
breakerCooldown: TimeSpan.FromSeconds(10));
// If the server is down, this will fail immediately (no retry)
// after it accumulates 3 failures
try
{
await resilient.ExecuteAsync("SELECT COUNT(*) FROM users");
}
catch (CircuitBreakerOpenException ex)
{
if (ex.RetryAfter > TimeSpan.Zero)
Console.WriteLine($"Breaker open, retry after {ex.RetryAfter}");
else
Console.WriteLine("Breaker half-open; another call already probing");
}
Backoff Tuning
Retry delays grow exponentially with full jitter (AWS "full jitter" strategy):
Formula: delay_before_retry_N = full_jitter( min(baseDelay * 2^N, maxRetryDelay) )
Where:
N= retry number (0-based; first retry is N=0)baseDelay=retryDelayparam (default 200 ms)maxRetryDelay= max cap (default 5 s)full_jitter= uniformly random in[0, cap](not[0.5*cap, cap]or other variants)
Example delays (baseDelay=200ms, maxRetryDelay=5s):
| Retry | Min cap | Full-jitter range |
|---|---|---|
| 0 (1st retry) | 200 ms | [0, 200 ms] |
| 1 (2nd retry) | 400 ms | [0, 400 ms] |
| 2 (3rd retry) | 800 ms | [0, 800 ms] |
| 3+ | 5 s | [0, 5 s] |
Why full jitter? If all workers retry in lockstep, they hammer the recovering server in a thundering herd. Full jitter spreads them out randomly.
Tuning:
// Aggressive: retry fast (50ms base, 1s max) — good for flaky local networks
await using var resilient = await ResilientHoloDbClient.ConnectAsync(
"db.local", 5432,
retryDelay: TimeSpan.FromMilliseconds(50),
maxRetryDelay: TimeSpan.FromSeconds(1));
// Conservative: slow backoff (1s base, 30s max) — good when server recovery is slow
await using var resilient = await ResilientHoloDbClient.ConnectAsync(
"db.example.com", 5432,
retryDelay: TimeSpan.FromSeconds(1),
maxRetryDelay: TimeSpan.FromSeconds(30));
Exceptions
HoloProtocolException
Framing, handshake, or low-level connection error (not an application-level SQL error). Thrown by any operation when the wire protocol state is unexpected (server closes mid-handshake, frame format is corrupt, connection drops before a reply arrives, etc). Retried by ResilientHoloDbClient (transient).
using HoloDb.Protocol;
try
{
await db.ExecuteAsync("SELECT COUNT(*) FROM users");
}
catch (HoloProtocolException ex)
{
Console.WriteLine($"Protocol error: {ex.Message}");
// Connection is likely dead. Reconnect if desired.
}
HoloVersionMismatchException
Thrown by ConnectAsync when the server does not support this client's protocol version (or the server reports a version outside the client's range). The handshake fails before any SQL is sent. Both sides must run builds that speak the same protocol version.
using HoloDb.Protocol;
try
{
await HoloDbClient.ConnectAsync("db.example.com", 5432);
}
catch (HoloVersionMismatchException ex)
{
Console.WriteLine($"Upgrade the client or the server: {ex.Message}");
}
HoloRemoteException
Application-level error reported by the server (bad SQL, auth failure, constraint violation, etc). Sent over a live connection and decoded successfully. Never retried by ResilientHoloDbClient (proves the connection works; the problem is the request, not the network).
using HoloDb.Protocol;
try
{
await db.ExecuteAsync("INSERT INTO users (id) VALUES (null)"); // id is NOT NULL
}
catch (HoloRemoteException ex)
{
Console.WriteLine($"Query error: {ex.Message}");
// Not a transient error; check your SQL/data.
}
CircuitBreakerOpenException
The circuit breaker rejected the call outright (Open state, cooldown not yet elapsed, or HalfOpen with a probe already in flight). No connection attempt was made. A RetryAfter property says when to retry (or Zero if a concurrent probe is already checking).
Not retried by ResilientHoloDbClient (retrying would defeat the breaker). A caller wanting to keep working should back off (e.g., skip this worker's turn) rather than retry immediately.
try
{
await resilient.ExecuteAsync("SELECT COUNT(*) FROM users");
}
catch (CircuitBreakerOpenException ex)
{
if (ex.RetryAfter > TimeSpan.Zero)
{
Console.WriteLine($"Breaker open. Retry after {ex.RetryAfter}.");
await Task.Delay(ex.RetryAfter);
// ... try again on next request, or skip this turn
}
else
{
Console.WriteLine("Breaker half-open; another caller is already probing.");
}
}
TimeoutException
Operation deadline exceeded (either HoloDbClient.ConnectAsync(..., connectTimeout) or ResilientHoloDbClient.ExecuteAsync(..., operationTimeout)). Distinct from OperationCanceledException (caller-driven cancellation via CancellationToken). Neither is retried by resilient client (both indicate "our own budget ran out", not a server failure).
await using var resilient = await ResilientHoloDbClient.ConnectAsync("db.example.com", 5432);
try
{
await resilient.ExecuteAsync("SELECT COUNT(*) FROM users", operationTimeout: TimeSpan.FromSeconds(2));
}
catch (TimeoutException)
{
Console.WriteLine("Operation took too long");
}
catch (OperationCanceledException)
{
Console.WriteLine("Caller cancelled via CancellationToken");
}
ObjectDisposedException
Thrown when a method is called on a disposed client/pool. Retried by ResilientHoloDbClient (if the underlying session was disposed and a fresh one is created, the retry may succeed).
var db = await HoloDbClient.ConnectAsync("localhost", 5432);
await db.DisposeAsync();
try
{
await db.ExecuteAsync("SELECT COUNT(*) FROM users");
}
catch (ObjectDisposedException)
{
Console.WriteLine("Client is disposed");
}
Connection Lifecycle & Gotchas
One Session Per Connection
Each HoloDbClient is one TCP+TLS session. Transactions (BEGIN/COMMIT/ROLLBACK) are scoped to it:
await db.ExecuteAsync("BEGIN");
await db.ExecuteAsync("INSERT INTO t VALUES (1)");
// If the connection drops here, the transaction is lost.
await db.ExecuteAsync("COMMIT");
If the connection drops mid-transaction and you reconnect, that transaction is rolled back on the server, and the new session starts fresh. Only ResilientHoloDbClient can reconnect automatically; HoloDbClient leaves it to you.
Requests Serialize
Concurrent callers on the same HoloDbClient are queued (internal SemaphoreSlim(1, 1)). They do not parallelize:
// DON'T DO THIS on a single client:
var t1 = db.ExecuteAsync("SELECT ... "); // waits for mutex
var t2 = db.ExecuteAsync("SELECT ... "); // waits in queue
await Task.WhenAll(t1, t2); // t2 only runs after t1 completes
// DO THIS instead: one client per concurrent worker (use HoloDbClientPool)
TLS Certificate Validation
TLS is on by default (UseTls = true). Trust model:
Pinned thumbprint (recommended): set
PinnedThumbprintto the server's cert SHA1. Server prints it on startup. Ignores CA chain and hostname; accepts the cert iff thumbprints match. Safest for self-hosted/self-signed.var opts = new HoloDbClientOptions { PinnedThumbprint = "1A2B3C..." };AllowUntrustedCertificate (dev only): accept any cert (no MITM protection).
var opts = new HoloDbClientOptions { AllowUntrustedCertificate = true };CA chain (production): neither pinning nor allowing untrusted → standard CA chain validation (cert must be signed by a trusted root).
Wire-Compatibility Version
The client and server exchange a protocol version byte in the handshake. The server checks it and refuses an unsupported client with a VersionMismatch reply; the client then throws HoloVersionMismatchException from ConnectAsync. Client and server must be built against a HoloDb.Protocol that speaks the same version.
Reconnect Loses Transaction State
A dropped connection (or an intentional reconnect via ResilientHoloDbClient) means a brand-new session:
var resilient = await ResilientHoloDbClient.ConnectAsync("db.example.com", 5432);
await resilient.ExecuteAsync("BEGIN");
// Connection drops; auto-retry creates a fresh session
await resilient.ExecuteAsync("INSERT INTO t VALUES (1)");
// This runs in a NEW transaction context; the original BEGIN is lost.
Only wrap idempotent operations (or ones safe to re-run) in resilient retry. Don't rely on mid-transaction state surviving a reconnect.
Cancel & Timeout Composition (ResilientHoloDbClient)
When both a caller CancellationToken and an operationTimeout deadline can expire, ordering matters:
- A cancelled
ctalways wins first (checked on entry and each retry-loop iteration). - An expired
operationTimeoutdeadline fails fast before the circuit breaker is consulted (prevents hammering an Open breaker while out of time). - Neither cancellation nor a deadline timeout counts as a circuit-breaker pass/fail (both are "caller's budget", not server health).
- A retry is never started if its backoff cap alone would exceed remaining budget (prevents sleeping for a delay that would exceed the deadline).
- A deadline only gates decision points, not in-flight I/O — a hung read inside one attempt is bounded by
ct, notoperationTimeout. Pair a real boundedctwithoperationTimeoutif you need both.
Example:
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); // caller budget
try
{
await resilient.ExecuteAsync(
"SELECT COUNT(*) FROM users",
operationTimeout: TimeSpan.FromSeconds(15), // operation budget
ct: cts.Token);
// If caller's 10s expires, OperationCanceledException is thrown.
// If operation's 15s expires (and caller's 10s hasn't), TimeoutException is thrown.
}
catch (OperationCanceledException) { Console.WriteLine("Caller cancelled"); }
catch (TimeoutException) { Console.WriteLine("Operation deadline exceeded"); }
Thread Safety & Concurrency
HoloDbClient & ResilientHoloDbClient
Not thread-safe for concurrent calls. Use one per concurrent worker (or share via HoloDbClientPool). Concurrent callers on one instance queue through an internal mutex — they serialize anyway, so contention is high and you lose throughput.
// Share a pool, not a single client:
await using var pool = await HoloDbClientPool.ConnectAsync("db.example.com", 5432, size: 8);
var t1 = Task.Run(async () =>
{
using (var lease = await pool.RentAsync())
await lease.Client.ExecuteAsync("SELECT COUNT(*) FROM users");
});
var t2 = Task.Run(async () =>
{
using (var lease = await pool.RentAsync())
await lease.Client.ExecuteAsync("SELECT 2");
});
await Task.WhenAll(t1, t2);
HoloDbClientPool
Thread-safe. Multiple threads can call RentAsync concurrently.
Lease
Not thread-safe (it's a readonly struct). Don't share a rented client across threads. Dispose the lease in the thread that rented it.
using (var lease = await pool.RentAsync())
{
// Don't pass lease.Client to another thread
await lease.Client.ExecuteAsync("SELECT COUNT(*) FROM users");
} // Lease disposed in same thread
Server Discovery & Deployment
Server Setup
The server is HoloDb.Server (the host that listens for this client). Its command line, as built:
# Default: TLS with a generated dev certificate, loopback 127.0.0.1:5432, in-memory database.
# The server prints the dev certificate thumbprint at startup; pin it on the client.
dotnet HoloDb.Server.dll
# Durable WAL, auth token, all interfaces, your own certificate
dotnet HoloDb.Server.dll --wal /data/db.wal --host 0.0.0.0 --port 5432 \
--token mysecret --cert /path/to/cert.pfx --cert-pass secret
# Plaintext, loopback development only
dotnet HoloDb.Server.dll --no-tls
Flags: --wal, --host, --port, --token, --cert, --cert-pass, --txn-timeout <seconds> (idle open transaction
rolled back after this long, default 30), --no-tls, --allow-no-auth, --selftest. The server refuses to bind a
non-loopback address without --token unless --allow-no-auth is given.
Connection Strings
This package does not use connection strings. Configure via HoloDbClientOptions or ConnectAsync params:
// Not supported:
// var connStr = "Server=db.example.com;Port=5432;Token=secret;TlsThumbprint=ABC...";
// Do this instead:
var opts = new HoloDbClientOptions
{
AuthToken = "secret",
PinnedThumbprint = "ABC..."
};
await using var db = await HoloDbClient.ConnectAsync("db.example.com", 5432, opts);
Advanced: Combining Pool + Resilience
For high reliability with many workers, combine pooling and resilience:
// Create a pool of resilient clients
var clients = new List<ResilientHoloDbClient>();
for (var i = 0; i < 8; i++)
{
clients.Add(await ResilientHoloDbClient.ConnectAsync(
"db.example.com", 5432,
maxAttempts: 3,
breakerFailureThreshold: 5,
breakerCooldown: TimeSpan.FromSeconds(30)));
}
// Distribute across workers
var tasks = clients.Select(async c =>
{
try
{
var result = await c.ExecuteAsync(
"SELECT COUNT(*) FROM large_table",
operationTimeout: TimeSpan.FromSeconds(30));
Console.WriteLine($"Count: {result.GetValue(0, 0)}");
}
catch (CircuitBreakerOpenException)
{
Console.WriteLine("Server is down; skipping this worker's turn");
}
finally
{
await c.DisposeAsync();
}
});
await Task.WhenAll(tasks);
License
Proprietary — this package ships the compiled library only; see LICENSE for the full terms. Installing and using unmodified copies for internal evaluation and non-commercial development or testing is licensed; commercial, production, or revenue-generating use requires a separate commercial licence from the owner. No feature on this page is gated behind a tier or a licence key — every capability described works identically with or without one. Copyright (c) 2026 Dongyang Stephen Chen, trading as Evaluated Applications. All rights reserved.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | 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. |
-
net10.0
- EvaluatedApplications.HoloDb.Protocol (>= 1.1.3)
-
net8.0
- EvaluatedApplications.HoloDb.Protocol (>= 1.1.3)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.