EvaluatedApplications.HoloDb.Client 1.5.1

There is a newer version of this package available.
See the version list below for details.
dotnet add package EvaluatedApplications.HoloDb.Client --version 1.5.1
                    
NuGet\Install-Package EvaluatedApplications.HoloDb.Client -Version 1.5.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="EvaluatedApplications.HoloDb.Client" Version="1.5.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="EvaluatedApplications.HoloDb.Client" Version="1.5.1" />
                    
Directory.Packages.props
<PackageReference Include="EvaluatedApplications.HoloDb.Client" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add EvaluatedApplications.HoloDb.Client --version 1.5.1
                    
#r "nuget: EvaluatedApplications.HoloDb.Client, 1.5.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package EvaluatedApplications.HoloDb.Client@1.5.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=EvaluatedApplications.HoloDb.Client&version=1.5.1
                    
Install as a Cake Addin
#tool nuget:?package=EvaluatedApplications.HoloDb.Client&version=1.5.1
                    
Install as a Cake Tool

HoloDb.Client

A thin TCP+TLS .NET client for a remote HoloDb server. It mirrors the in-process HoloDbService surface exactly — Execute / ExecuteAsync / BulkLoadAsync and the same QueryResult type — so moving from embedded to networked changes only how you get the handle. Columnar results stay columnar over the wire (typed arrays, no JSON). 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)
var result = await db.ExecuteAsync("SELECT id, name FROM users WHERE active = true");
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["query_count"]}");

// 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

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). Throws HoloRemoteException on app-level errors (bad SQL, auth failure) and HoloProtocolException on connection failure.

var result = await db.ExecuteAsync(
    "SELECT id, name, age FROM users WHERE age > 18");

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);
    var age = result.GetLong(i, 2);
    Console.WriteLine($"  {id}: {name} ({age})");
}

QueryResult Execute(string sql)

Synchronous wrapper over ExecuteAsync (blocking). Avoid on UI threads. Prefer ExecuteAsync in async code.

var result = db.Execute("SELECT 1 AS n");
Console.WriteLine($"Result: {result.GetLong(0, 0)}");
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 (e.g. long[], string[], double[], etc). count is the number of rows. Returns number of rows inserted.

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 server performance counters (uptime, connection count, query count, transaction commits/rollbacks, aborts, WAL size, etc). Keys and values depend on the server version.

var stats = await db.GetStatsAsync();
Console.WriteLine($"Connections: {stats["connection_count"]}");
Console.WriteLine($"Queries: {stats["query_count"]}");
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).

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

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.GetLong(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.

try
{
    var result = await resilient.ExecuteAsync(
        "SELECT 1",
        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 42");

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 1");
}
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 = retryDelay param (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).

try
{
    await db.ExecuteAsync("SELECT 1");
}
catch (HoloProtocolException ex)
{
    Console.WriteLine($"Protocol error: {ex.Message}");
    // Connection is likely dead. Reconnect if desired.
}

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).

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 1");
}
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).

try
{
    await db.ExecuteAsync("SELECT 1", 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 1");
}
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:

  1. Pinned thumbprint (recommended): set PinnedThumbprint to 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..." };
    
  2. AllowUntrustedCertificate (dev only): accept any cert (no MITM protection).

    var opts = new HoloDbClientOptions { AllowUntrustedCertificate = true };
    
  3. 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 version byte during handshake (HoloProtocol.Version). Currently NOT enforced — both sides send and receive it, but neither validates. A version mismatch causes opaque decode errors downstream, not a clean rejection. Stay in sync by using the same HoloDb.Protocol package version on client and server.

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:

  1. A cancelled ct always wins first (checked on entry and each retry-loop iteration).
  2. An expired operationTimeout deadline fails fast before the circuit breaker is consulted (prevents hammering an Open breaker while out of time).
  3. Neither cancellation nor a deadline timeout counts as a circuit-breaker pass/fail (both are "caller's budget", not server health).
  4. A retry is never started if its backoff cap alone would exceed remaining budget (prevents sleeping for a delay that would exceed the deadline).
  5. A deadline only gates decision points, not in-flight I/O — a hung read inside one attempt is bounded by ct, not operationTimeout. Pair a real bounded ct with operationTimeout if you need both.

Example:

var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); // caller budget
try
{
    await resilient.ExecuteAsync(
        "SELECT 1",
        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 1");
});

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 1");
} // Lease disposed in same thread

Server Discovery & Deployment

Server Setup

Run a HoloDb.Server instance (NuGet package EvaluatedApplications.HoloDb.Server):

# Start with default options (TCP 5432, no TLS, no token)
dotnet HoloDb.Server.dll

# With TLS and auth
dotnet HoloDb.Server.dll --useTls --cert-path /path/to/cert.pfx --cert-password secret --token mysecret

# Server prints its cert thumbprint on startup:
# [INFO] Certificate thumbprint: 1A2B3C...

Docker

A Docker image is available (EvaluatedApplications.HoloDb.Server):

docker run -p 5432:5432 \
  -e HOLO_AUTH_TOKEN=mysecret \
  -e HOLO_USE_TLS=true \
  evaluated-applications/holo-db-server

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.GetLong(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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.5.3 128 10/17/2026
1.5.2 113 10/3/2026
1.5.1 109 10/2/2026
1.5.0 129 9/4/2026
1.4.0 106 8/26/2026
1.1.0 121 8/25/2026
1.0.0 126 8/15/2026