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.
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
ICSharpDbClientcontract 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
ICSharpDbClientfrom 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
INSERTcan also use isolated write transactions whenImplicitInsertExecutionMode = 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.
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:
HybridIncrementalDurablekeeps the durable file as the source of truth while letting the daemon hold touched pages resident in memory.ConcurrentWriteTransactionsmoves shared auto-commit inserts onto the isolated write-transaction path instead of the older serialized insert path.UseWriteOptimizedPreset = truegives 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
HybridIncrementalDurablefor the normal long-running daemon shape. - Keep
ConcurrentWriteTransactionswhen you want the current recommended shared-insert path and you have not benchmarked a reason to turn it off. - Keep
UseWriteOptimizedPreseton unless you are deliberately measuring a different host tuning shape.
When to override them:
- Switch
OpenModetoDirectonly when you explicitly do not want the hybrid lazy-resident cache behavior. - Switch
ImplicitInsertExecutionModetoSerializedif 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
ICSharpDbClientinstances 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/.walfiles 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.