Multi-Writer gRPC Daemon Tutorial

Host one CSharpDB database behind CSharpDB.Daemon, let multiple app clients talk to it over gRPC, and understand what the current multi-writer model does and does not guarantee.

Important boundary: gRPC is only the transport. The multi-writer behavior lives inside the daemon-hosted database/runtime. Putting CSharpDB behind gRPC does not turn it into a distributed write coordinator or a multi-tenant database server.

What this pattern is for

This is the current remote-host pattern when you want one long-running CSharpDB process to own the database file and serve multiple application clients through a stable gRPC endpoint.

  • Use it when backend services or local tools should not open the database file directly.
  • Use it when you want one authoritative ICSharpDbClient contract across app processes.
  • Use it for sidecar-style deployment, internal networks, local development, or service-to-service communication.
  • Prefer direct embedded access only when the caller can safely own the database file in-process and does not need process isolation.

How the daemon works

CSharpDB.Daemon is a thin gRPC host over CSharpDB.Client. One daemon process manages one database file, opens it during startup, and exposes explicit gRPC methods that map to the public client operations.

app client(s)
    └── gRPC
        └── CSharpDB.Daemon
            └── ICSharpDbClient (direct transport inside the host)
                └── one daemon-hosted Database instance
                    └── one .db / .wal durable state

Today the daemon host:

  • starts an ASP.NET Core host with gRPC enabled
  • binds one direct ICSharpDbClient from configuration
  • opens and validates the configured database during startup
  • maps gRPC methods to explicit client operations such as GetInfo, ExecuteSql, BeginTransaction, and maintenance commands

All remote clients talk to the same daemon-hosted database runtime. That is why write concurrency behavior is shared: the overlap happens inside that one host process.

What multi-writer means today

CSharpDB ships initial multi-writer support, but it is important to describe it precisely.

  • Auto-commit UPDATE, DELETE, and DDL already run through isolated write-transaction state internally on a shared database handle.
  • Shared auto-commit INSERT can also use isolated write transactions when ImplicitInsertExecutionMode = ConcurrentWriteTransactions.
  • The daemon defaults already enable that insert mode, so the recommended gRPC host shape is multi-writer-capable out of the box.
  • The physical WAL publish and durable flush path is still serialized at the storage boundary, so this is not unlimited write parallelism.
Expectation setting: current gains depend on conflict shape. Lower-conflict write workloads benefit more than hot right-edge insert loops. Hot insert fan-in remains the most workload-sensitive part of the shipped model.

The practical meaning is: the daemon can overlap write preparation and use conflict-managed commit flow for more shapes than the old single-owner path, but durable commit finalization is still not a free-for-all.

Recommended daemon configuration

The current recommended daemon shape is already the default in src/CSharpDB.Daemon/appsettings.json:

{
  "ConnectionStrings": {
    "CSharpDB": "Data Source=csharpdb.db"
  },
  "CSharpDB": {
    "HostDatabase": {
      "OpenMode": "HybridIncrementalDurable",
      "ImplicitInsertExecutionMode": "ConcurrentWriteTransactions",
      "UseWriteOptimizedPreset": true,
      "HotTableNames": [],
      "HotCollectionNames": []
    }
  }
}

Why these defaults exist:

  • HybridIncrementalDurable keeps the durable file as the source of truth while letting the daemon hold touched pages resident in memory.
  • ConcurrentWriteTransactions moves shared auto-commit inserts onto the isolated write-transaction path instead of the older serialized insert path.
  • UseWriteOptimizedPreset = true gives the daemon a sensible starting point for durable remote write workloads.

Use environment variables when deploying the daemon as a service:

$env:ConnectionStrings__CSharpDB = "Data Source=C:\\data\\app.db"
$env:CSharpDB__HostDatabase__OpenMode = "HybridIncrementalDurable"
$env:CSharpDB__HostDatabase__ImplicitInsertExecutionMode = "ConcurrentWriteTransactions"
$env:CSharpDB__HostDatabase__UseWriteOptimizedPreset = "true"
$env:ASPNETCORE_URLS = "http://0.0.0.0:5820"

When to keep the defaults:

  • Keep HybridIncrementalDurable for the normal long-running daemon shape.
  • Keep ConcurrentWriteTransactions when you want the current recommended shared-insert path and you have not benchmarked a reason to turn it off.
  • Keep UseWriteOptimizedPreset on unless you are deliberately measuring a different host tuning shape.

When to override them:

  • Switch OpenMode to Direct only when you explicitly do not want the hybrid lazy-resident cache behavior.
  • Switch ImplicitInsertExecutionMode to Serialized if your own hot insert benchmark shows the legacy serialized insert path is a better fit for that workload.

Use it from CSharpDB.Client

The primary app-facing abstraction is CSharpDB.Client. Your application talks to the daemon through the same client contract used by the direct and HTTP transports.

using CSharpDB.Client;

await using var client = CSharpDbClient.Create(new CSharpDbClientOptions
{
    Transport = CSharpDbTransport.Grpc,
    Endpoint = "http://localhost:5820",
});

var info = await client.GetInfoAsync();
var tables = await client.GetTableNamesAsync();

var create = await client.ExecuteSqlAsync("""
    CREATE TABLE IF NOT EXISTS orders (
        id INTEGER PRIMARY KEY,
        customer_id INTEGER NOT NULL,
        status TEXT NOT NULL
    );
    """);

var rows = await client.ExecuteSqlAsync(
    "SELECT id, customer_id, status FROM orders ORDER BY id LIMIT 20;");

When one caller needs a multi-step atomic unit, use a client-managed transaction session rather than assuming separate remote calls are automatically grouped together:

using CSharpDB.Client;

await using var client = CSharpDbClient.Create(new CSharpDbClientOptions
{
    Transport = CSharpDbTransport.Grpc,
    Endpoint = "http://localhost:5820",
});

var tx = await client.BeginTransactionAsync();

try
{
    await client.ExecuteInTransactionAsync(
        tx.TransactionId,
        "INSERT INTO orders (id, customer_id, status) VALUES (101, 42, 'Pending');");

    await client.ExecuteInTransactionAsync(
        tx.TransactionId,
        "UPDATE customers SET last_order_id = 101 WHERE id = 42;");

    await client.CommitTransactionAsync(tx.TransactionId);
}
catch
{
    await client.RollbackTransactionAsync(tx.TransactionId);
    throw;
}

That example is still using the daemon-hosted runtime under the hood. The transaction session is remote, but the real write coordination and durability work still happen inside the daemon process.

Practical guidance:

  • Keep ICSharpDbClient instances alive instead of reconnecting per operation.
  • Reuse the same gRPC channel/client for many requests.
  • Use one transaction session per caller-owned atomic unit; do not try to share one transaction session across unrelated callers.

Use it from ADO.NET

If your app already uses DbConnection, DbCommand, and DbTransaction, point CSharpDB.Data at the daemon with a gRPC transport connection string:

using CSharpDB.Data;

await using var connection = new CSharpDbConnection(
    "Transport=Grpc;Endpoint=http://localhost:5820");

await connection.OpenAsync();

await using var command = connection.CreateCommand();
command.CommandText = "SELECT COUNT(*) FROM orders";

var count = (long)(await command.ExecuteScalarAsync() ?? 0L);

ADO.NET transactions work against the daemon-managed database as well:

await using var connection = new CSharpDbConnection(
    "Transport=Grpc;Endpoint=http://localhost:5820");

await connection.OpenAsync();

await using var tx = await connection.BeginTransactionAsync();
await using var command = connection.CreateCommand();
command.Transaction = (CSharpDbTransaction)tx;

command.CommandText = "INSERT INTO orders (id, customer_id, status) VALUES (102, 42, 'Queued')";
await command.ExecuteNonQueryAsync();

command.CommandText = "UPDATE customers SET last_order_id = 102 WHERE id = 42";
await command.ExecuteNonQueryAsync();

await tx.CommitAsync();

Keep remote ADO.NET connections open for bursts of work instead of reconnecting for every command.

Operational guidance

  • Run one daemon process per database file.
  • Treat the database directory as durable application state and keep the daemon responsible for those files while it is running.
  • Do not let unrelated processes mutate the same .db / .wal files directly while the daemon owns them.
  • Prefer private networking and front-end TLS termination because built-in auth and authorization are not shipped in the daemon yet.
  • Make sure the full network path supports HTTP/2 correctly if the daemon sits behind infrastructure.
  • Reuse clients and connections so the app spends time doing useful work instead of rebuilding transport state.

Limits and expectations

  • This is not a multi-tenant database server.
  • This is not a public-internet-ready database endpoint with built-in auth, authorization, or service hardening.
  • This is not cross-process distributed write coordination across multiple daemon processes.
  • This does not guarantee that every write-heavy workload scales linearly with client count.
  • Hot insert workloads can still see limited fan-in depending on key pattern, row-id pressure, and right-edge page growth.

CLI inspection and querying

The CLI is useful for inspecting and querying the daemon-managed database over gRPC. It is not the tool that changes the daemon’s concurrency model.

csharpdb -- --endpoint http://localhost:5820 --transport grpc

csharpdb> .info
csharpdb> .tables
csharpdb> SELECT COUNT(*) FROM orders;
csharpdb> SELECT status, COUNT(*) FROM orders GROUP BY status ORDER BY status;

For broader CLI coverage, use the CLI Reference. For the surrounding host/tooling picture, see Tools & Ecosystem.