Inside CSharpDB v3.3.0: Faster Durable Writes & Easier Tuning

CSharpDB v3.3.0 shipped April 21, 2026. It is a durable-write release — the numbers below come straight from the balanced --release-core benchmark suite, promoted after a clean release guardrail compare (PASS=185, WARN=0, SKIP=0, FAIL=0). Two themes drive the release:

  1. Faster durable writes. Append-optimized index storage, hot-right-edge recovery, and focused write-path tuning push B10000 durable bulk ingest to 798K rows/sec on the release runner — 1.48× the matched SQLite WAL+FULL baseline on the same machine.
  2. Easier embedded tuning. The storage-engine presets that already existed on DatabaseOptions are now exposed through CSharpDB.Data connection strings and through the CSharpDB.EntityFrameworkCore provider builder. Picking a write-heavy profile is now a one-line connection string change.

The rest of this post walks through the promoted scorecard, the new tuning surface, and the code recipe that gets you to the headline 798K rows/sec number.

The Release-Core Scorecard

These are the promoted v3.3.0 numbers, captured with --release-core --repeat 3 --repro (priority=High, affinity=0xFF) on an Intel i9-11900K / Windows 10.0.26300 / .NET 10.0.6 runner. Every row is file-backed and durable unless the row says otherwise.

798.25K
rows/sec — durable InsertBatch B10000
1.33M
ops/sec — SQL point lookup, warm
10.77M
ops/sec — 8-reader concurrent COUNT(*)
1.04K
commits/sec — W8 durable, 250μs window

The detailed scorecard:

Area Metric Result Source CSV row
SQL durable write Single INSERT 279.4 ops/sec master
SQL durable write Batch x100 26.71K rows/sec master
Single-writer ingest InsertBatch B1000 204.03K rows/sec batching
Single-writer ingest InsertBatch B10000 798.25K rows/sec batching
SQL hot read Point lookup 1.33M ops/sec master
SQL concurrent read 8 readers, burst x32 10.77M COUNT(*) ops/sec master
Collection hot read Point Get 1.67M ops/sec master
Concurrent durable write W8, 250μs commit window 1.04K commits/sec (3.98 commits/flush) concurrent
Resident hot set Hybrid hot-set SQL burst 535.39K ops/sec hotset
Local SQLite reference WAL+FULL B1000 prepared bulk 192.06K rows/sec sqlite

The master, batching, concurrent, hotset, and sqlite artifacts map to specific timestamped median-of-3 CSV files pinned in release-core-manifest.json. The full per-benchmark breakdown lives in the benchmark suite's generated README; this post stays focused on the rows that moved in v3.3.0.

What Shipped in v3.3.0

The release notes call out five changes that matter for users. The first three are internals that move the numbers; the last two are user-visible behavior/surface changes.

Append-Optimized Index Storage

Row-id chains and hashed payloads now have dedicated append-optimized codecs and overflow paths. Insert-maintenance on monotonic and append-heavy workloads benefits the most — this is where the bulk of the B10000 bulk-ingest gain comes from. The paths are internal; no API changes needed to opt in.

Hot Right-Edge Recovery

Indexed inserts that keep hitting the right edge of a B+tree (the common shape for monotonic primary keys) now recover faster after contention events. Combined with the shared routing-cache work from v3.0.0 and v3.2.0, monotonic PK-only workloads are the strongest path on the write side.

Trailing-Integer Composite Grouped Aggregates

The planner can now route grouped aggregates through a composite index when the grouping key is a prefix of the index and the trailing column is an integer. This is the first concrete user-visible piece of the phase-2 planner statistics work that began in v2.9.0.

Behavior Change: Multi-Column Index Metadata Defaults

Multi-column indexes created in v3.3.0 no longer receive trailing-integer hash options by default. If you were relying on the previous implicit behavior for a specific workload, add the option explicitly when creating the index. For the vast majority of callers this is invisible, but it is the one backwards-compatible-with-a-footnote change in the release — worth noting if you hand-tune index metadata.

ADO.NET and EF Core Storage Tuning Surface

The storage engine has long exposed presets through StorageEngineOptionsBuilder. Until v3.3.0, reaching them from CSharpDB.Data or the EF Core provider meant constructing DatabaseOptions by hand. Now there is a direct path through both surfaces. That is significant enough to get its own section.

The New Tuning Surface

Embedded users now have three entry points depending on how much control they want. All three are additive — nothing about existing code changes unless you opt in.

Connection-String Keywords (Simplest)

Two new keywords on the ADO.NET connection string: Storage Preset and Embedded Open Mode. Case-insensitive, parsed by CSharpDbConnectionStringBuilder, and flow through to the underlying engine when the connection opens.

using CSharpDB.Data;

await using var conn = new CSharpDbConnection(
    "Data Source=app.cdb;" +
    "Storage Preset=WriteOptimized;" +
    "Embedded Open Mode=Direct");

await conn.OpenAsync();

EF Core Provider Builder

The EF Core provider gets UseStoragePreset() and UseEmbeddedOpenMode() extension methods. Both take the new CSharpDbStoragePreset and CSharpDbEmbeddedOpenMode enums, so the values are discoverable and compile-checked.

using CSharpDB.Engine;
using CSharpDB.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

var options = new DbContextOptionsBuilder<AppDbContext>()
    .UseCSharpDb("Data Source=app.cdb", csharpdb =>
    {
        csharpdb.UseStoragePreset(CSharpDbStoragePreset.WriteOptimized);
        csharpdb.UseEmbeddedOpenMode(CSharpDbEmbeddedOpenMode.Direct);
    })
    .Options;

Explicit DatabaseOptions (Full Control)

If the preset convenience surface does not cover your case, CSharpDbConnection.DirectDatabaseOptions and HybridDatabaseOptions accept the full engine options object. EF Core has the matching UseDirectDatabaseOptions() and UseHybridDatabaseOptions(). Explicit options always win over the connection-string keywords when both are present, which is the right precedence for the "I know exactly what I want" case.

Preset Decision Table

Five presets ship in v3.3.0. Use this table to pick a starting point; measure before adopting any non-default:

Preset Use when
WriteOptimized Durable file-backed ingest, long-lived write-heavy services, multi-writer disjoint keys
LowLatencyDurableWrite Measure-first variant with deferred advisory planner-stat persistence
DirectLookupOptimized Hot local file-backed lookup-heavy workloads
DirectColdFileLookup Cold / cache-pressured direct file reads; memory-mapped clean-page path
HybridFileCache Explicit bounded file-cache experiments

And three open modes:

Embedded Open Mode Semantics
Direct Standard file-backed open with full durability
HybridIncrementalDurable Lazy-resident pages with durable backing file; on-demand loading
HybridSnapshot Resident hot-set with checkpointed snapshot durability

Pooling remains supported for file-backed direct opens, and the pool key is options-aware: separate explicit options object instances do not share a pool in v1, even if their contents are equivalent.

Getting to 798K rows/sec

The headline B10000 number is not a lab curiosity — it is a realistic configuration for any app that ingests rows in batches. Four levers combine to produce it; if you skip any of them, the rate drops roughly proportionally.

  1. Use InsertBatch, not per-row SQL. The bulk path ships a whole batch as one multi-row engine call, collapsing per-row parse / plan / bind cost into a single operation.
  2. Commit at B10000. Larger batches amortize the fsync cost. B1000 lands at 204K rows/sec; B10000 at 798K. B100000 would help less because at that point you are batching faster than the WAL frame chunk sizes help with.
  3. Start from UseWriteOptimizedPreset(). This preset raises the checkpoint frame threshold and moves checkpoint work to a background slice, so the commit path stays tight during ingest.
  4. Keep primary keys monotonic. Monotonic PKs keep the table B-tree growing on the right edge, reduce split-heavy locality loss, and hit the new hot-right-edge recovery path. Random keys are still materially slower on every storage engine; the gap just moves.

End-to-end, that configuration looks like this:

using CSharpDB.Engine;
using CSharpDB.Primitives;

var options = new DatabaseOptions()
    .ConfigureStorageEngine(builder => builder.UseWriteOptimizedPreset());

await using var db = await Database.OpenAsync("ingest.cdb", options);

await db.ExecuteAsync("""
    CREATE TABLE IF NOT EXISTS bench (
        id       INTEGER PRIMARY KEY,
        value    INTEGER,
        text_col TEXT,
        category TEXT
    )
    """);

var batch = db.PrepareInsertBatch("bench", initialCapacity: 10_000);

for (int block = 0; block < 100; block++)
{
    for (int i = 0; i < 10_000; i++)
    {
        int id = (block * 10_000) + i;
        batch.AddRow(
            DbValue.FromInteger(id),
            DbValue.FromInteger(id),
            DbValue.FromText("durable_batch"),
            DbValue.FromText("Alpha"));
    }

    await batch.ExecuteAsync();
}

That is the same loop used in DurableSqlBatchingBenchmark.cs row DurableSqlBatching_BatchSweep_InsertBatch_B10000_Baseline_PkOnly_Monotonic_10s, which is the direct source of the 798.25K number in the scorecard above.

For ADO.NET users who do not want to drop to the engine API, the same loop via CSharpDbConnection with the new connection-string keyword lands in the same band:

using CSharpDB.Data;

await using var conn = new CSharpDbConnection(
    "Data Source=ingest.cdb;Storage Preset=WriteOptimized;Embedded Open Mode=Direct");

await conn.OpenAsync();

// Prepared command, parameter reuse, explicit transaction batching —
// the same ADO.NET fast path that works on SQLite also works here.

Durable Concurrent Writes

v3.3.0 does not retarget the concurrent-writer defaults, but the --concurrent-write-diagnostics row in the scorecard (1.04K commits/sec at W8 with a 250μs commit window) shows what the existing opt-in knobs can do when combined. Two knobs matter, and both are still measure-first:

  • ImplicitInsertExecutionMode.ConcurrentWriteTransactions — routes shared auto-commit inserts through isolated WriteTransaction state. Important for disjoint-key workloads; the wrong choice for hot right-edge loops.
  • UseDurableGroupCommit(...) — gives the flush leader a short window to coalesce overlapping commits into a single OS fsync. At W8 on the release runner, this collapses eight would-be fsyncs down to roughly two, hence the 3.98 commits/flush.

Both remain expert-only: the single-writer default preset is still the right starting point if you do not know your concurrency shape. Section 5 of the performance guide has the full configuration story.

SQLite Comparison Highlights

Since this is a release-core run, every row appears in both the internal scorecard and the focused SQLite comparison:

Workload CSharpDB SQLite WAL+FULL Ratio
Durable B10000 bulk insert 798.25K rows/sec 539.56K rows/sec 1.48×
Durable B1000 bulk insert 204.03K rows/sec 192.06K rows/sec 1.06×
SQL point lookup (warm) 1.33M ops/sec 138.33K ops/sec 9.63×
8-reader COUNT(*) burst 10.77M ops/sec 109.99K ops/sec 97.95×

Both sides run durable (SQLite uses journal_mode=WAL; synchronous=FULL, private cache, pooling disabled) and both sides reuse prepared work on the insert path. The full ratio table, latency percentiles, and scenario-to-source mapping live in SQLITE_COMPARISON.md. For the deeper decision guide — which API to pick for which workload — see the benchmark-driven guide.

Reproducing the Numbers

Every row in this post reproduces with the same --release-core command the release uses:

dotnet run -c Release `
    --project .\tests\CSharpDB.Benchmarks\CSharpDB.Benchmarks.csproj `
    -- --release-core --repeat 3 --repro

Or target individual surfaces:

# Durable ingest sweep (B1, B100, B1000, B10000)
dotnet run -c Release --project .\tests\CSharpDB.Benchmarks\CSharpDB.Benchmarks.csproj `
    -- --durable-sql-batching --repeat 3 --repro

# Concurrent durable writes (W4/W8 with and without batch window)
dotnet run -c Release --project .\tests\CSharpDB.Benchmarks\CSharpDB.Benchmarks.csproj `
    -- --concurrent-write-diagnostics --repeat 3 --repro

# Same-machine SQLite comparison
dotnet run -c Release --project .\tests\CSharpDB.Benchmarks\CSharpDB.Benchmarks.csproj `
    -- --sqlite-compare --repeat 3 --repro

Absolute numbers will vary with your CPU, disk, and filesystem. The ratios should be stable — if they move significantly, open an issue on the repo with the runner details and the release guardrail output.

Closing Thoughts

The two v3.3.0 themes are deliberately paired. The durable-write performance work makes WriteOptimized a meaningfully stronger default for ingest-heavy workloads, and the new connection-string surface means ADO.NET and EF Core users can reach for that preset without leaving their provider of choice. No new APIs are required on the write path itself — PrepareInsertBatch is still the bulk-load API, and the same four levers from the benchmark-driven guide still get you to the headline number.

What is not in v3.3.0 is just as important: no changes to default batch windows, no changes to default commit policy, and no promises around the hot insert auto-commit path (which still benefits most from explicit WriteTransaction use). Those remain workload-specific questions with workload-specific answers, and the benchmark suite is there to help you answer them for your own machine.