diff --git a/.github/workflows/dotnet-crm-benchmark.yml b/.github/workflows/dotnet-crm-benchmark.yml
new file mode 100644
index 00000000..e5d95981
--- /dev/null
+++ b/.github/workflows/dotnet-crm-benchmark.yml
@@ -0,0 +1,255 @@
+name: .NET CRM Benchmark Gates
+
+on:
+ pull_request:
+ schedule:
+ - cron: "30 3 * * *"
+ release:
+ types: [released]
+ workflow_dispatch:
+ inputs:
+ baseline-ado-relaxed:
+ description: "Optional path to baseline `ado-relaxed/results.json`"
+ required: false
+ default: ""
+ baseline-ado-durable:
+ description: "Optional path to baseline `ado-durable/results.json`"
+ required: false
+ default: ""
+ benchmark-size:
+ description: "Primary benchmark scale for scheduled/release runs"
+ required: false
+ default: "Small"
+ extra-size:
+ description: "Optional extra scale for release validation (e.g. Large)"
+ required: false
+ default: ""
+ iterations:
+ description: "Measured iterations per mode"
+ required: false
+ default: "3"
+ warmup:
+ description: "Warmup iterations per mode"
+ required: false
+ default: "1"
+ include-native:
+ description: "Run DecentDB native-only modes in matrix"
+ required: false
+ default: false
+ type: boolean
+
+jobs:
+ smoke:
+ if: github.event_name == 'pull_request'
+ name: PR Smoke
+ runs-on: ubuntu-latest
+ env:
+ BENCH_SIZE: "Tiny"
+ DURABILITY: "relaxed"
+ ITERATIONS: "1"
+ WARMUP: "0"
+ SEED: "42"
+ OUT_ROOT: ".tmp/dotnet-crm-smoke"
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+
+ - name: Set up .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: "10.0.x"
+
+ - name: Set up Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Build native DecentDB
+ run: cargo build --release -p decentdb
+
+ - name: Run smoke benchmark
+ run: |
+ set -euo pipefail
+ bash bindings/dotnet/benchmarks/DecentDB.CrmComparison/run-crm-benchmark.sh \
+ "$OUT_ROOT" \
+ "$BENCH_SIZE" \
+ "$DURABILITY" \
+ "$ITERATIONS" \
+ "$WARMUP" \
+ 0 \
+ "$SEED" \
+ all
+
+ RESULT_DIR="$(find "$OUT_ROOT" -maxdepth 1 -mindepth 1 -type d | sort | tail -n 1)"
+ if [[ -z "$RESULT_DIR" || ! -f "$RESULT_DIR/results.json" ]]; then
+ echo "::error::Smoke benchmark did not produce results at $RESULT_DIR/results.json" >&2
+ exit 1
+ fi
+ python bindings/dotnet/benchmarks/DecentDB.CrmComparison/compare-crm-benchmark.py \
+ --current "$RESULT_DIR/results.json" \
+ --require-complete \
+ --expected-engines "DecentDB,SQLite" \
+ --max-mean-ms 60000 \
+ --output "$RESULT_DIR/smoke-validation.json"
+ echo "Smoke validation passed: $RESULT_DIR/results.json"
+
+ - name: Upload smoke artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: dotnet-crm-smoke-${{ github.run_id }}
+ path: .tmp/dotnet-crm-smoke
+ retention-days: 7
+
+ nightly:
+ if: github.event_name == 'schedule'
+ name: Nightly Small Matrix
+ runs-on: ubuntu-latest
+ env:
+ SIZE: ${{ github.event.inputs.benchmark-size || 'Small' }}
+ ITERATIONS: ${{ github.event.inputs.iterations || '3' }}
+ WARMUP: ${{ github.event.inputs.warmup || '1' }}
+ OUT_ROOT: .tmp/dotnet-crm-nightly
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+
+ - name: Set up .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: "10.0.x"
+
+ - name: Set up Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Build native DecentDB
+ run: cargo build --release -p decentdb
+
+ - name: Run nightly matrix benchmark
+ run: |
+ set -euo pipefail
+ bash bindings/dotnet/benchmarks/DecentDB.CrmComparison/run-crm-benchmark-matrix.sh \
+ --size "${SIZE}" \
+ --iterations "${ITERATIONS}" \
+ --warmup "${WARMUP}" \
+ --out-dir "$OUT_ROOT" \
+ --run-id "nightly-${GITHUB_RUN_ID}"
+
+ - name: Upload nightly artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: dotnet-crm-nightly-${{ github.run_id }}
+ path: .tmp/dotnet-crm-nightly
+ retention-days: 30
+
+ release:
+ if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
+ name: Release Benchmark
+ runs-on: ubuntu-latest
+ env:
+ SIZE: ${{ github.event_name == 'workflow_dispatch' && inputs.benchmark-size || 'Small' }}
+ EXTRA_SIZE: ${{ github.event_name == 'workflow_dispatch' && inputs.extra-size || '' }}
+ ITERATIONS: ${{ github.event_name == 'workflow_dispatch' && inputs.iterations || '3' }}
+ WARMUP: ${{ github.event_name == 'workflow_dispatch' && inputs.warmup || '1' }}
+ INCLUDE_NATIVE: ${{ github.event_name == 'workflow_dispatch' && inputs.include-native || 'false' }}
+ BASELINE_RELAXED: ${{ github.event_name == 'workflow_dispatch' && inputs.baseline-ado-relaxed || '' }}
+ BASELINE_DURABLE: ${{ github.event_name == 'workflow_dispatch' && inputs.baseline-ado-durable || '' }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+
+ - name: Set up .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: "10.0.x"
+
+ - name: Set up Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Build native DecentDB
+ run: cargo build --release -p decentdb
+
+ - name: Run release matrix benchmark
+ id: run
+ run: |
+ set -euo pipefail
+ OUT_ROOT=".tmp/dotnet-crm-release"
+ PRIMARY_RUN_ID="release-${GITHUB_RUN_ID}-$SIZE"
+ bash bindings/dotnet/benchmarks/DecentDB.CrmComparison/run-crm-benchmark-matrix.sh \
+ --size "$SIZE" \
+ --iterations "$ITERATIONS" \
+ --warmup "$WARMUP" \
+ --out-dir "$OUT_ROOT" \
+ --run-id "$PRIMARY_RUN_ID" \
+ $(if [[ "$INCLUDE_NATIVE" == "true" ]]; then echo "--native-on"; fi)
+
+ if [[ -n "${EXTRA_SIZE:-}" ]]; then
+ EXTRA_RUN_ID="release-${GITHUB_RUN_ID}-${EXTRA_SIZE}"
+ bash bindings/dotnet/benchmarks/DecentDB.CrmComparison/run-crm-benchmark-matrix.sh \
+ --size "$EXTRA_SIZE" \
+ --iterations "$ITERATIONS" \
+ --warmup "$WARMUP" \
+ --out-dir "$OUT_ROOT" \
+ --run-id "$EXTRA_RUN_ID" \
+ $(if [[ "$INCLUDE_NATIVE" == "true" ]]; then echo "--native-on"; fi)
+ fi
+
+ echo "primary_release_dir=$OUT_ROOT/$PRIMARY_RUN_ID" >> "$GITHUB_OUTPUT"
+
+ if [[ -n "${EXTRA_SIZE:-}" ]]; then
+ echo "extra_release_dir=$OUT_ROOT/$EXTRA_RUN_ID" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Compare release baseline (when provided)
+ if: env.BASELINE_RELAXED != '' || env.BASELINE_DURABLE != ''
+ run: |
+ set -euo pipefail
+ PRIMARY_DIR="${{ steps.run.outputs.primary_release_dir }}"
+ RELEASE_RELAXED="$PRIMARY_DIR/ado-relaxed/results.json"
+ RELEASE_DURABLE="$PRIMARY_DIR/ado-durable/results.json"
+
+ if [[ -n "${BASELINE_RELAXED}" ]]; then
+ if [[ ! -f "$BASELINE_RELAXED" ]]; then
+ echo "::error::Baseline path not found: $BASELINE_RELAXED" >&2
+ exit 1
+ fi
+
+ if [[ ! -f "$RELEASE_RELAXED" ]]; then
+ echo "::error::Missing release current results: $RELEASE_RELAXED" >&2
+ exit 1
+ fi
+
+ python bindings/dotnet/benchmarks/DecentDB.CrmComparison/compare-crm-benchmark.py \
+ --baseline "$BASELINE_RELAXED" \
+ --current "$RELEASE_RELAXED" \
+ --max-regression 0.10 \
+ --check-decentdb-win \
+ --require-complete \
+ --expected-engines "DecentDB,SQLite" \
+ --output "$PRIMARY_DIR/compare-ado-relaxed.json"
+ fi
+
+ if [[ -n "${BASELINE_DURABLE}" ]]; then
+ if [[ ! -f "$BASELINE_DURABLE" ]]; then
+ echo "::error::Baseline path not found: $BASELINE_DURABLE" >&2
+ exit 1
+ fi
+
+ if [[ ! -f "$RELEASE_DURABLE" ]]; then
+ echo "::error::Missing release current results: $RELEASE_DURABLE" >&2
+ exit 1
+ fi
+
+ python bindings/dotnet/benchmarks/DecentDB.CrmComparison/compare-crm-benchmark.py \
+ --baseline "$BASELINE_DURABLE" \
+ --current "$RELEASE_DURABLE" \
+ --max-regression 0.10 \
+ --check-decentdb-win \
+ --require-complete \
+ --expected-engines "DecentDB,SQLite" \
+ --output "$PRIMARY_DIR/compare-ado-durable.json"
+ fi
+
+ - name: Upload release artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: dotnet-crm-release-${{ github.run_id }}
+ path: .tmp/dotnet-crm-release
+ retention-days: 90
diff --git a/Cargo.lock b/Cargo.lock
index 044847e0..3025f7ce 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -831,7 +831,7 @@ dependencies = [
[[package]]
name = "decentdb"
-version = "2.15.0"
+version = "2.16.0"
dependencies = [
"base64 0.22.1",
"chacha20",
@@ -867,7 +867,7 @@ dependencies = [
[[package]]
name = "decentdb-benchmark"
-version = "2.15.0"
+version = "2.16.0"
dependencies = [
"anyhow",
"clap",
@@ -881,7 +881,7 @@ dependencies = [
[[package]]
name = "decentdb-cli"
-version = "2.15.0"
+version = "2.16.0"
dependencies = [
"anyhow",
"clap",
@@ -895,7 +895,7 @@ dependencies = [
[[package]]
name = "decentdb-migrate"
-version = "2.15.0"
+version = "2.16.0"
dependencies = [
"anyhow",
"clap",
@@ -1859,7 +1859,7 @@ dependencies = [
[[package]]
name = "libpg_query_sys"
-version = "2.15.0"
+version = "2.16.0"
dependencies = [
"pg_query",
]
diff --git a/Cargo.toml b/Cargo.toml
index 43c7ef88..df061379 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -12,7 +12,7 @@ exclude = [
resolver = "2"
[workspace.package]
-version = "2.15.0"
+version = "2.16.0"
edition = "2021"
authors = ["Steven Hildreth"]
license = "Apache-2.0"
diff --git a/VERSION b/VERSION
index 68e69e40..75249069 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.15.0
+2.16.0
diff --git a/benchmarks/rust-baseline/Cargo.lock b/benchmarks/rust-baseline/Cargo.lock
index a89176bd..c19f29ea 100644
--- a/benchmarks/rust-baseline/Cargo.lock
+++ b/benchmarks/rust-baseline/Cargo.lock
@@ -694,7 +694,7 @@ dependencies = [
[[package]]
name = "decentdb"
-version = "2.15.0"
+version = "2.16.0"
dependencies = [
"base64",
"chacha20",
@@ -1531,7 +1531,7 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libpg_query_sys"
-version = "2.15.0"
+version = "2.16.0"
dependencies = [
"pg_query",
]
diff --git a/bindings/dart/dart/pubspec.yaml b/bindings/dart/dart/pubspec.yaml
index 426c3967..1d0e3dce 100644
--- a/bindings/dart/dart/pubspec.yaml
+++ b/bindings/dart/dart/pubspec.yaml
@@ -1,6 +1,6 @@
name: decentdb
description: Dart FFI bindings for the Rust DecentDB C ABI.
-version: 2.15.0
+version: 2.16.0
repository: https://github.com/sphildreth/decentdb
homepage: https://github.com/sphildreth/decentdb/tree/main/bindings/dart
diff --git a/bindings/dart/examples/console/pubspec.lock b/bindings/dart/examples/console/pubspec.lock
index 23da5aa4..067d6d82 100644
--- a/bindings/dart/examples/console/pubspec.lock
+++ b/bindings/dart/examples/console/pubspec.lock
@@ -7,7 +7,7 @@ packages:
path: "../../dart"
relative: true
source: path
- version: "2.15.0"
+ version: "2.16.0"
ffi:
dependency: transitive
description:
diff --git a/bindings/dart/examples/console_complex/pubspec.lock b/bindings/dart/examples/console_complex/pubspec.lock
index 23da5aa4..067d6d82 100644
--- a/bindings/dart/examples/console_complex/pubspec.lock
+++ b/bindings/dart/examples/console_complex/pubspec.lock
@@ -7,7 +7,7 @@ packages:
path: "../../dart"
relative: true
source: path
- version: "2.15.0"
+ version: "2.16.0"
ffi:
dependency: transitive
description:
diff --git a/bindings/dart/examples/flutter_desktop/pubspec.lock b/bindings/dart/examples/flutter_desktop/pubspec.lock
index 23da5aa4..067d6d82 100644
--- a/bindings/dart/examples/flutter_desktop/pubspec.lock
+++ b/bindings/dart/examples/flutter_desktop/pubspec.lock
@@ -7,7 +7,7 @@ packages:
path: "../../dart"
relative: true
source: path
- version: "2.15.0"
+ version: "2.16.0"
ffi:
dependency: transitive
description:
diff --git a/bindings/dart/flutter/android/build.gradle b/bindings/dart/flutter/android/build.gradle
index a57b2f82..efaa586f 100644
--- a/bindings/dart/flutter/android/build.gradle
+++ b/bindings/dart/flutter/android/build.gradle
@@ -3,7 +3,7 @@ plugins {
}
group = 'dev.decentdb.decentdb_flutter'
-version = '2.15.0'
+version = '2.16.0'
android {
namespace 'dev.decentdb.decentdb_flutter'
diff --git a/bindings/dart/flutter/example/pubspec.lock b/bindings/dart/flutter/example/pubspec.lock
index 91d1b815..28d072f1 100644
--- a/bindings/dart/flutter/example/pubspec.lock
+++ b/bindings/dart/flutter/example/pubspec.lock
@@ -71,14 +71,14 @@ packages:
path: "../../dart"
relative: true
source: path
- version: "2.15.0"
+ version: "2.16.0"
decentdb_flutter:
dependency: "direct main"
description:
path: ".."
relative: true
source: path
- version: "2.15.0"
+ version: "2.16.0"
fake_async:
dependency: transitive
description:
diff --git a/bindings/dart/flutter/example/pubspec.yaml b/bindings/dart/flutter/example/pubspec.yaml
index f2ca17ab..2b9e0ec8 100644
--- a/bindings/dart/flutter/example/pubspec.yaml
+++ b/bindings/dart/flutter/example/pubspec.yaml
@@ -1,7 +1,7 @@
name: decentdb_flutter_example
description: Reference Flutter mobile app for DecentDB.
publish_to: none
-version: 2.15.0
+version: 2.16.0
environment:
sdk: ^3.0.0
diff --git a/bindings/dart/flutter/ios/decentdb_flutter.podspec b/bindings/dart/flutter/ios/decentdb_flutter.podspec
index 97fe4cbd..fac4d637 100644
--- a/bindings/dart/flutter/ios/decentdb_flutter.podspec
+++ b/bindings/dart/flutter/ios/decentdb_flutter.podspec
@@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'decentdb_flutter'
- s.version = '2.15.0'
+ s.version = '2.16.0'
s.summary = 'Flutter mobile integration helpers for DecentDB.'
s.description = 'Provides Flutter registration and native artifact wiring for the DecentDB Dart FFI package.'
s.homepage = 'https://github.com/sphildreth/decentdb'
diff --git a/bindings/dart/flutter/pubspec.lock b/bindings/dart/flutter/pubspec.lock
index da4c907c..8ea01a2b 100644
--- a/bindings/dart/flutter/pubspec.lock
+++ b/bindings/dart/flutter/pubspec.lock
@@ -71,7 +71,7 @@ packages:
path: "../dart"
relative: true
source: path
- version: "2.15.0"
+ version: "2.16.0"
fake_async:
dependency: transitive
description:
diff --git a/bindings/dart/flutter/pubspec.yaml b/bindings/dart/flutter/pubspec.yaml
index 2da74733..ff320db9 100644
--- a/bindings/dart/flutter/pubspec.yaml
+++ b/bindings/dart/flutter/pubspec.yaml
@@ -1,6 +1,6 @@
name: decentdb_flutter
description: Flutter mobile integration helpers for the DecentDB Dart FFI package.
-version: 2.15.0
+version: 2.16.0
publish_to: none
repository: https://github.com/sphildreth/decentdb
homepage: https://github.com/sphildreth/decentdb/tree/main/bindings/dart/flutter
diff --git a/bindings/dart/native/decentdb.h b/bindings/dart/native/decentdb.h
index 791ca3b8..86e4edb9 100644
--- a/bindings/dart/native/decentdb.h
+++ b/bindings/dart/native/decentdb.h
@@ -320,8 +320,8 @@ ddb_status_t ddb_stmt_execute_batch_i64_text_f64(
ddb_status_t ddb_stmt_execute_batch_typed(
ddb_stmt_t *stmt,
size_t row_count,
- const char *signature,
- const int64_t *values_i64,
+ const char *signature, /* 'i'=INT64, 'b'=BOOLEAN, 'f'=FLOAT64, 't'=TEXT */
+ const int64_t *values_i64, /* INT64 plus BOOLEAN slots; BOOLEAN uses 0/non-zero */
const double *values_f64,
const char *const *values_text_ptrs,
const size_t *values_text_lens,
diff --git a/bindings/dotnet/DecentDB.NET.sln b/bindings/dotnet/DecentDB.NET.sln
index f3efee9b..a699558c 100644
--- a/bindings/dotnet/DecentDB.NET.sln
+++ b/bindings/dotnet/DecentDB.NET.sln
@@ -31,6 +31,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{B3
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DecentDb.ShowCase", "examples\DecentDb.ShowCase\DecentDb.ShowCase.csproj", "{22D1E471-8BF8-4F30-92B6-B7FA0CB36EC3}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DecentDB.CrmComparison", "benchmarks\DecentDB.CrmComparison\DecentDB.CrmComparison.csproj", "{70866133-5EEC-4937-B7CC-D0AEF2D8FF04}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DecentDB.AdoNetMicrobenchmarks", "benchmarks\DecentDB.AdoNetMicrobenchmarks\DecentDB.AdoNetMicrobenchmarks.csproj", "{CAB277FF-B90A-4DCA-84D5-B038C64B23A5}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -173,6 +177,30 @@ Global
{22D1E471-8BF8-4F30-92B6-B7FA0CB36EC3}.Release|x64.Build.0 = Release|Any CPU
{22D1E471-8BF8-4F30-92B6-B7FA0CB36EC3}.Release|x86.ActiveCfg = Release|Any CPU
{22D1E471-8BF8-4F30-92B6-B7FA0CB36EC3}.Release|x86.Build.0 = Release|Any CPU
+ {70866133-5EEC-4937-B7CC-D0AEF2D8FF04}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {70866133-5EEC-4937-B7CC-D0AEF2D8FF04}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {70866133-5EEC-4937-B7CC-D0AEF2D8FF04}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {70866133-5EEC-4937-B7CC-D0AEF2D8FF04}.Debug|x64.Build.0 = Debug|Any CPU
+ {70866133-5EEC-4937-B7CC-D0AEF2D8FF04}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {70866133-5EEC-4937-B7CC-D0AEF2D8FF04}.Debug|x86.Build.0 = Debug|Any CPU
+ {70866133-5EEC-4937-B7CC-D0AEF2D8FF04}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {70866133-5EEC-4937-B7CC-D0AEF2D8FF04}.Release|Any CPU.Build.0 = Release|Any CPU
+ {70866133-5EEC-4937-B7CC-D0AEF2D8FF04}.Release|x64.ActiveCfg = Release|Any CPU
+ {70866133-5EEC-4937-B7CC-D0AEF2D8FF04}.Release|x64.Build.0 = Release|Any CPU
+ {70866133-5EEC-4937-B7CC-D0AEF2D8FF04}.Release|x86.ActiveCfg = Release|Any CPU
+ {70866133-5EEC-4937-B7CC-D0AEF2D8FF04}.Release|x86.Build.0 = Release|Any CPU
+ {CAB277FF-B90A-4DCA-84D5-B038C64B23A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {CAB277FF-B90A-4DCA-84D5-B038C64B23A5}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {CAB277FF-B90A-4DCA-84D5-B038C64B23A5}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {CAB277FF-B90A-4DCA-84D5-B038C64B23A5}.Debug|x64.Build.0 = Debug|Any CPU
+ {CAB277FF-B90A-4DCA-84D5-B038C64B23A5}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {CAB277FF-B90A-4DCA-84D5-B038C64B23A5}.Debug|x86.Build.0 = Debug|Any CPU
+ {CAB277FF-B90A-4DCA-84D5-B038C64B23A5}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {CAB277FF-B90A-4DCA-84D5-B038C64B23A5}.Release|Any CPU.Build.0 = Release|Any CPU
+ {CAB277FF-B90A-4DCA-84D5-B038C64B23A5}.Release|x64.ActiveCfg = Release|Any CPU
+ {CAB277FF-B90A-4DCA-84D5-B038C64B23A5}.Release|x64.Build.0 = Release|Any CPU
+ {CAB277FF-B90A-4DCA-84D5-B038C64B23A5}.Release|x86.ActiveCfg = Release|Any CPU
+ {CAB277FF-B90A-4DCA-84D5-B038C64B23A5}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -182,5 +210,7 @@ Global
{7E2321E0-7290-41D8-883B-8D32915FEB23} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{5E0BB005-E02E-4010-BE07-EA7577B5D397} = {66320409-64EC-F7C5-3DEF-65E7510DAAD1}
{22D1E471-8BF8-4F30-92B6-B7FA0CB36EC3} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F}
+ {70866133-5EEC-4937-B7CC-D0AEF2D8FF04} = {66320409-64EC-F7C5-3DEF-65E7510DAAD1}
+ {CAB277FF-B90A-4DCA-84D5-B038C64B23A5} = {66320409-64EC-F7C5-3DEF-65E7510DAAD1}
EndGlobalSection
EndGlobal
diff --git a/bindings/dotnet/benchmarks/DecentDB.AdoNetMicrobenchmarks/DecentDB.AdoNetMicrobenchmarks.csproj b/bindings/dotnet/benchmarks/DecentDB.AdoNetMicrobenchmarks/DecentDB.AdoNetMicrobenchmarks.csproj
new file mode 100644
index 00000000..e7b2bfd6
--- /dev/null
+++ b/bindings/dotnet/benchmarks/DecentDB.AdoNetMicrobenchmarks/DecentDB.AdoNetMicrobenchmarks.csproj
@@ -0,0 +1,14 @@
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
diff --git a/bindings/dotnet/benchmarks/DecentDB.AdoNetMicrobenchmarks/Program.cs b/bindings/dotnet/benchmarks/DecentDB.AdoNetMicrobenchmarks/Program.cs
new file mode 100644
index 00000000..33f8e3cf
--- /dev/null
+++ b/bindings/dotnet/benchmarks/DecentDB.AdoNetMicrobenchmarks/Program.cs
@@ -0,0 +1,312 @@
+using System.Data;
+using System.Data.Common;
+using System.Globalization;
+using BenchmarkDotNet.Attributes;
+using BenchmarkDotNet.Configs;
+using BenchmarkDotNet.Order;
+using BenchmarkDotNet.Running;
+using DecentDB.AdoNet;
+using Microsoft.Data.Sqlite;
+
+var config = ManualConfig
+ .Create(DefaultConfig.Instance)
+ .WithArtifactsPath(Path.Combine(".tmp", "adonet-microbenchmarks", "artifacts"));
+BenchmarkSwitcher.FromAssembly(typeof(AdoNetHotPathBenchmarks).Assembly).Run(args, config);
+
+[MemoryDiagnoser]
+[Orderer(SummaryOrderPolicy.Declared)]
+public class AdoNetHotPathBenchmarks
+{
+ private const int SeedRows = 4096;
+ private const string Payload = "payload";
+
+ private string? _databasePath;
+ private DbConnection? _connection;
+ private DbTransaction? _transaction;
+
+ private DbCommand? _insertCommand;
+ private DbParameter? _insertId;
+ private DbParameter? _insertValue;
+ private DbParameter? _insertPayload;
+
+ private DbCommand? _pointReadCommand;
+ private DbParameter? _pointReadId;
+
+ private DbCommand? _updateCommand;
+ private DbParameter? _updateId;
+ private DbParameter? _updateValue;
+
+ private DbCommand? _readerCommand;
+ private DbParameter? _readerId;
+
+ private DbCommand? _executeNonQueryCommand;
+ private DbParameter? _executeNonQueryId;
+ private DbParameter? _executeNonQueryValue;
+
+ private DbCommand? _executeNonQueryAsyncCommand;
+ private DbParameter? _executeNonQueryAsyncId;
+ private DbParameter? _executeNonQueryAsyncValue;
+
+ private long _nextInsertId = SeedRows;
+ private int _nextPointReadId = 1;
+ private int _nextUpdateId = 1;
+ private int _nextReaderId = 1;
+ private int _nextExecuteNonQueryId = 1;
+ private int _nextExecuteNonQueryAsyncId = 1;
+ private long _nextUpdateValue;
+
+ [Params(BenchmarkProvider.DecentDB, BenchmarkProvider.SQLite)]
+ public BenchmarkProvider Provider { get; set; }
+
+ [GlobalSetup]
+ public void GlobalSetup()
+ {
+ _databasePath = CreateDatabasePath(Provider);
+ DeleteDatabaseFiles(_databasePath);
+
+ _connection = CreateConnection(Provider, _databasePath);
+ _connection.Open();
+ ConfigureConnection(_connection, Provider);
+ ExecuteNonQuery(_connection, null, "CREATE TABLE hot (id INTEGER PRIMARY KEY, value INTEGER NOT NULL, payload TEXT NOT NULL)");
+ ExecuteNonQuery(_connection, null, "CREATE TABLE insert_sink (id INTEGER PRIMARY KEY, value INTEGER NOT NULL, payload TEXT NOT NULL)");
+ SeedHotTable(_connection);
+
+ _transaction = _connection.BeginTransaction();
+ CreatePreparedCommands(_connection, _transaction);
+ }
+
+ [GlobalCleanup]
+ public void GlobalCleanup()
+ {
+ DisposeCommands();
+
+ try
+ {
+ _transaction?.Rollback();
+ }
+ catch (InvalidOperationException)
+ {
+ }
+ catch (DbException)
+ {
+ }
+
+ _transaction?.Dispose();
+ _connection?.Dispose();
+
+ if (_databasePath != null)
+ {
+ DeleteDatabaseFiles(_databasePath);
+ }
+ }
+
+ [Benchmark]
+ public int PreparedOneRowInsert()
+ {
+ var id = ++_nextInsertId;
+ _insertId!.Value = id;
+ _insertValue!.Value = id;
+ _insertPayload!.Value = Payload;
+ return _insertCommand!.ExecuteNonQuery();
+ }
+
+ [Benchmark]
+ public long PreparedPointReadScalar()
+ {
+ _pointReadId!.Value = NextId(ref _nextPointReadId);
+ var value = _pointReadCommand!.ExecuteScalar();
+ return Convert.ToInt64(value, CultureInfo.InvariantCulture);
+ }
+
+ [Benchmark]
+ public int PreparedOneRowUpdate()
+ {
+ _updateId!.Value = NextId(ref _nextUpdateId);
+ _updateValue!.Value = ++_nextUpdateValue;
+ return _updateCommand!.ExecuteNonQuery();
+ }
+
+ [Benchmark]
+ public long ReaderCreationDisposal()
+ {
+ _readerId!.Value = NextId(ref _nextReaderId);
+ using var reader = _readerCommand!.ExecuteReader(CommandBehavior.SingleRow);
+ if (!reader.Read())
+ {
+ throw new InvalidOperationException("Expected the point-read reader to return one row.");
+ }
+
+ return reader.GetInt64(0);
+ }
+
+ [Benchmark(Baseline = true)]
+ public int ExecuteNonQuerySync()
+ {
+ _executeNonQueryId!.Value = NextId(ref _nextExecuteNonQueryId);
+ _executeNonQueryValue!.Value = ++_nextUpdateValue;
+ return _executeNonQueryCommand!.ExecuteNonQuery();
+ }
+
+ [Benchmark]
+ public async Task ExecuteNonQueryAsync()
+ {
+ _executeNonQueryAsyncId!.Value = NextId(ref _nextExecuteNonQueryAsyncId);
+ _executeNonQueryAsyncValue!.Value = ++_nextUpdateValue;
+ return await _executeNonQueryAsyncCommand!.ExecuteNonQueryAsync().ConfigureAwait(false);
+ }
+
+ private static DbConnection CreateConnection(BenchmarkProvider provider, string path)
+ {
+ return provider switch
+ {
+ BenchmarkProvider.DecentDB => new DecentDBConnection(
+ $"Data Source={path};Cache Size=128MB;Retain Paged Row Sources After Commit=True;Paged Row Storage=False;WAL Auto Checkpoint=0"),
+ BenchmarkProvider.SQLite => new SqliteConnection($"Data Source={path}"),
+ _ => throw new ArgumentOutOfRangeException(nameof(provider), provider, null),
+ };
+ }
+
+ private static void ConfigureConnection(DbConnection connection, BenchmarkProvider provider)
+ {
+ if (provider != BenchmarkProvider.SQLite)
+ {
+ return;
+ }
+
+ ExecuteNonQuery(connection, null, "PRAGMA journal_mode=WAL");
+ ExecuteNonQuery(connection, null, "PRAGMA synchronous=NORMAL");
+ ExecuteNonQuery(connection, null, "PRAGMA foreign_keys=ON");
+ ExecuteNonQuery(connection, null, "PRAGMA temp_store=MEMORY");
+ ExecuteNonQuery(connection, null, "PRAGMA cache_size=-65536");
+ }
+
+ private static void SeedHotTable(DbConnection connection)
+ {
+ using var transaction = connection.BeginTransaction();
+ using var command = CreateCommand(connection, transaction, "INSERT INTO hot (id, value, payload) VALUES (@id, @value, @payload)");
+ var id = AddParameter(command, "@id", DbType.Int64);
+ var value = AddParameter(command, "@value", DbType.Int64);
+ var payload = AddParameter(command, "@payload", DbType.String);
+ command.Prepare();
+
+ for (var i = 1; i <= SeedRows; i++)
+ {
+ id.Value = i;
+ value.Value = i;
+ payload.Value = Payload;
+ command.ExecuteNonQuery();
+ }
+
+ transaction.Commit();
+ }
+
+ private void CreatePreparedCommands(DbConnection connection, DbTransaction transaction)
+ {
+ _insertCommand = CreateCommand(connection, transaction, "INSERT INTO insert_sink (id, value, payload) VALUES (@id, @value, @payload)");
+ _insertId = AddParameter(_insertCommand, "@id", DbType.Int64);
+ _insertValue = AddParameter(_insertCommand, "@value", DbType.Int64);
+ _insertPayload = AddParameter(_insertCommand, "@payload", DbType.String);
+ Prepare(_insertCommand);
+
+ _pointReadCommand = CreateCommand(connection, transaction, "SELECT value FROM hot WHERE id = @id");
+ _pointReadId = AddParameter(_pointReadCommand, "@id", DbType.Int64);
+ Prepare(_pointReadCommand);
+
+ _updateCommand = CreateCommand(connection, transaction, "UPDATE hot SET value = @value WHERE id = @id");
+ _updateValue = AddParameter(_updateCommand, "@value", DbType.Int64);
+ _updateId = AddParameter(_updateCommand, "@id", DbType.Int64);
+ Prepare(_updateCommand);
+
+ _readerCommand = CreateCommand(connection, transaction, "SELECT value, payload FROM hot WHERE id = @id");
+ _readerId = AddParameter(_readerCommand, "@id", DbType.Int64);
+ Prepare(_readerCommand);
+
+ _executeNonQueryCommand = CreateCommand(connection, transaction, "UPDATE hot SET value = @value WHERE id = @id");
+ _executeNonQueryValue = AddParameter(_executeNonQueryCommand, "@value", DbType.Int64);
+ _executeNonQueryId = AddParameter(_executeNonQueryCommand, "@id", DbType.Int64);
+ Prepare(_executeNonQueryCommand);
+
+ _executeNonQueryAsyncCommand = CreateCommand(connection, transaction, "UPDATE hot SET value = @value WHERE id = @id");
+ _executeNonQueryAsyncValue = AddParameter(_executeNonQueryAsyncCommand, "@value", DbType.Int64);
+ _executeNonQueryAsyncId = AddParameter(_executeNonQueryAsyncCommand, "@id", DbType.Int64);
+ Prepare(_executeNonQueryAsyncCommand);
+ }
+
+ private void DisposeCommands()
+ {
+ _insertCommand?.Dispose();
+ _pointReadCommand?.Dispose();
+ _updateCommand?.Dispose();
+ _readerCommand?.Dispose();
+ _executeNonQueryCommand?.Dispose();
+ _executeNonQueryAsyncCommand?.Dispose();
+ }
+
+ private static DbCommand CreateCommand(DbConnection connection, DbTransaction? transaction, string sql)
+ {
+ var command = connection.CreateCommand();
+ command.CommandText = sql;
+ command.Transaction = transaction;
+ return command;
+ }
+
+ private static DbParameter AddParameter(DbCommand command, string name, DbType type)
+ {
+ var parameter = command.CreateParameter();
+ parameter.ParameterName = name;
+ parameter.DbType = type;
+ command.Parameters.Add(parameter);
+ return parameter;
+ }
+
+ private static void Prepare(DbCommand command)
+ {
+ command.Prepare();
+ }
+
+ private static void ExecuteNonQuery(DbConnection connection, DbTransaction? transaction, string sql)
+ {
+ using var command = CreateCommand(connection, transaction, sql);
+ command.ExecuteNonQuery();
+ }
+
+ private static long NextId(ref int nextId)
+ {
+ var id = nextId++;
+ if (nextId > SeedRows)
+ {
+ nextId = 1;
+ }
+
+ return id;
+ }
+
+ private static string CreateDatabasePath(BenchmarkProvider provider)
+ {
+ var root = Path.Combine(".tmp", "adonet-microbenchmarks", "databases");
+ Directory.CreateDirectory(root);
+ var extension = provider == BenchmarkProvider.SQLite ? ".db" : ".ddb";
+ return Path.Combine(root, $"{provider}-{Guid.NewGuid():N}{extension}");
+ }
+
+ private static void DeleteDatabaseFiles(string path)
+ {
+ var directory = Path.GetDirectoryName(path);
+ var prefix = Path.GetFileName(path);
+ if (directory == null || prefix.Length == 0 || !Directory.Exists(directory))
+ {
+ return;
+ }
+
+ foreach (var file in Directory.EnumerateFiles(directory, prefix + "*"))
+ {
+ File.Delete(file);
+ }
+ }
+}
+
+public enum BenchmarkProvider
+{
+ DecentDB,
+ SQLite,
+}
diff --git a/bindings/dotnet/benchmarks/DecentDB.AdoNetMicrobenchmarks/README.md b/bindings/dotnet/benchmarks/DecentDB.AdoNetMicrobenchmarks/README.md
new file mode 100644
index 00000000..e4221cbc
--- /dev/null
+++ b/bindings/dotnet/benchmarks/DecentDB.AdoNetMicrobenchmarks/README.md
@@ -0,0 +1,24 @@
+# DecentDB ADO.NET Microbenchmarks
+
+BenchmarkDotNet coverage for phase-2 ADO.NET hot paths in `DecentDB.AdoNet`, with the same benchmark methods run against DecentDB and SQLite.
+
+## Run
+
+```bash
+dotnet run --configuration Release --project bindings/dotnet/benchmarks/DecentDB.AdoNetMicrobenchmarks/DecentDB.AdoNetMicrobenchmarks.csproj
+```
+
+BenchmarkDotNet artifacts are written under `.tmp/adonet-microbenchmarks/artifacts`.
+Temporary database files are written under `.tmp/adonet-microbenchmarks/databases`
+and removed during benchmark cleanup.
+
+## Benchmarks
+
+- `PreparedOneRowInsert`
+- `PreparedPointReadScalar`
+- `PreparedOneRowUpdate`
+- `ReaderCreationDisposal`
+- `ExecuteNonQuerySync`
+- `ExecuteNonQueryAsync`
+
+Each benchmark reuses stable prepared command and parameter objects. The project enables BenchmarkDotNet `MemoryDiagnoser` so allocation counts are captured for both providers.
diff --git a/bindings/dotnet/benchmarks/DecentDB.Benchmarks/README.md b/bindings/dotnet/benchmarks/DecentDB.Benchmarks/README.md
index a8c81023..b235b308 100644
--- a/bindings/dotnet/benchmarks/DecentDB.Benchmarks/README.md
+++ b/bindings/dotnet/benchmarks/DecentDB.Benchmarks/README.md
@@ -2,6 +2,11 @@
This is a lightweight, dependency-free benchmark harness for the ADO.NET provider and the Micro-ORM.
+Additional benchmark suites:
+
+- `DecentDB.CrmComparison`: canonical CRM-style DecentDB vs SQLite comparison with JSON artifacts, matrix runner, validation, and optional allocation telemetry.
+- `DecentDB.AdoNetMicrobenchmarks`: BenchmarkDotNet ADO.NET hot-path microbenchmarks with memory diagnostics for prepared inserts, point reads, updates, reader creation, and sync vs async wrappers.
+
## Run
From repo root:
diff --git a/bindings/dotnet/benchmarks/DecentDB.CrmComparison/DecentDB.CrmComparison.csproj b/bindings/dotnet/benchmarks/DecentDB.CrmComparison/DecentDB.CrmComparison.csproj
new file mode 100644
index 00000000..95ec392b
--- /dev/null
+++ b/bindings/dotnet/benchmarks/DecentDB.CrmComparison/DecentDB.CrmComparison.csproj
@@ -0,0 +1,14 @@
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
diff --git a/bindings/dotnet/benchmarks/DecentDB.CrmComparison/Program.cs b/bindings/dotnet/benchmarks/DecentDB.CrmComparison/Program.cs
new file mode 100644
index 00000000..ead0c608
--- /dev/null
+++ b/bindings/dotnet/benchmarks/DecentDB.CrmComparison/Program.cs
@@ -0,0 +1,2531 @@
+using System;
+using System.Collections.Generic;
+using System.Data;
+using System.Data.Common;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Threading.Tasks;
+using DecentDB.AdoNet;
+using DecentDB.Native;
+using Microsoft.Data.Sqlite;
+
+namespace DecentDB.CrmComparison;
+
+public enum DatabaseEngine { DecentDB, SQLite }
+
+public enum EngineSelection
+{
+ All,
+ DecentDB,
+ SQLite
+}
+
+public enum ScenarioSize
+{
+ Tiny, // quick correctness check
+ Small, // thousands of rows
+ Medium, // hundreds of thousands
+ Large, // millions
+ Jumbo // many millions
+}
+
+public enum DurabilityProfile
+{
+ Relaxed,
+ Durable
+}
+
+public sealed record WorkloadProfile(
+ int Companies,
+ int UsersPerCompany,
+ int AddressesPerUser,
+ int InvoicesPerUser,
+ int ItemsPerInvoice,
+ int SearchSamples,
+ int PointReadSamples);
+
+public static class WorkloadProfiles
+{
+ public static WorkloadProfile Get(ScenarioSize size) => size switch
+ {
+ ScenarioSize.Tiny => new(10, 10, 2, 20, 5, 50, 50),
+ ScenarioSize.Small => new(100, 50, 2, 50, 5, 500, 500),
+ ScenarioSize.Medium => new(1000, 100, 2, 100, 5, 5000, 5000),
+ ScenarioSize.Large => new(5000, 200, 2, 200, 5, 20000, 20000),
+ ScenarioSize.Jumbo => new(10000, 500, 2, 500, 5, 50000, 50000),
+ _ => throw new ArgumentOutOfRangeException(nameof(size))
+ };
+
+ public static long TotalRows(WorkloadProfile p) =>
+ (long)p.Companies
+ + (long)p.Companies * p.UsersPerCompany
+ + (long)p.Companies * p.UsersPerCompany * p.AddressesPerUser
+ + (long)p.Companies * p.UsersPerCompany * p.InvoicesPerUser
+ + (long)p.Companies * p.UsersPerCompany * p.InvoicesPerUser * p.ItemsPerInvoice;
+}
+
+public sealed record WorkloadData(
+ int Seed,
+ DateTime BaseUtc,
+ IReadOnlyList PointReadUserIds,
+ IReadOnlyList SearchPatterns,
+ IReadOnlyList DeleteCompanyIds);
+
+public static class WorkloadDataFactory
+{
+ private static readonly string[] Roles = ["admin", "manager", "sales", "support", "viewer"];
+ private static readonly string[] Cities = ["Springfield", "Franklin", "Greenville", "Madison", "Clayton", "Riverside", "Austin", "Denver"];
+ private static readonly string[] Regions = ["CA", "TX", "NY", "FL", "WA", "CO", "IL", "OH"];
+
+ public static WorkloadData Create(WorkloadProfile profile, int seed)
+ {
+ var userCount = checked(profile.Companies * profile.UsersPerCompany);
+ var pointReads = new List(profile.PointReadSamples);
+ for (var i = 0; i < profile.PointReadSamples; i++)
+ {
+ pointReads.Add(1 + Range(seed, i, 1001, userCount));
+ }
+
+ var searchPatterns = new List(profile.SearchSamples);
+ for (var i = 0; i < profile.SearchSamples; i++)
+ {
+ var userId = 1 + Range(seed, i, 2001, userCount);
+ var token = userId.ToString("D9")[^3..];
+ searchPatterns.Add($"%{token}%");
+ }
+
+ var deleteCount = Math.Min(profile.Companies / 10, 10);
+ var deleteCompanyIds = Enumerable.Range(1, deleteCount).ToArray();
+ return new WorkloadData(seed, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), pointReads, searchPatterns, deleteCompanyIds);
+ }
+
+ public static string CompanyName(int id, int seed) =>
+ $"Company {id:D6} - {Hash(seed, id, 11) & 0xFFFF_FFFFu:x8}";
+
+ public static string Role(int userId, int seed) =>
+ Roles[Range(seed, userId, 21, Roles.Length)];
+
+ public static string City(int userId, int addressIndex, int seed) =>
+ Cities[Range(seed, userId, 31 + addressIndex, Cities.Length)];
+
+ public static string Region(int userId, int addressIndex, int seed) =>
+ Regions[Range(seed, userId, 41 + addressIndex, Regions.Length)];
+
+ public static DateTime DueDateUtc(int invoiceId, DateTime baseUtc, int seed) =>
+ baseUtc.AddDays(7 + Range(seed, invoiceId, 51, 83));
+
+ public static DateTime IssuedDateUtc(int invoiceId, DateTime baseUtc, int seed) =>
+ DueDateUtc(invoiceId, baseUtc, seed).AddDays(-30);
+
+ public static double InvoiceTotal(int invoiceId, int seed) =>
+ Math.Round(Unit(seed, invoiceId, 61) * 5000 + 50, 2);
+
+ public static bool InvoicePaid(int invoiceId, int seed) =>
+ Unit(seed, invoiceId, 71) > 0.7;
+
+ public static string Sku(int invoiceId, int itemIndex, int seed) =>
+ $"SKU-{1 + Range(seed, invoiceId, 81 + itemIndex, 9999):D5}";
+
+ public static double Quantity(int invoiceId, int itemIndex, int seed) =>
+ Math.Round(Unit(seed, invoiceId, 91 + itemIndex) * 10 + 1, 4);
+
+ public static double UnitPrice(int invoiceId, int itemIndex, int seed) =>
+ Math.Round(Unit(seed, invoiceId, 101 + itemIndex) * 100 + 5, 4);
+
+ private static int Range(int seed, int value, int salt, int exclusiveMax) =>
+ (int)(Hash(seed, value, salt) % (uint)exclusiveMax);
+
+ private static double Unit(int seed, int value, int salt) =>
+ (Hash(seed, value, salt) & 0x00FF_FFFFu) / (double)0x0100_0000u;
+
+ private static uint Hash(int seed, int value, int salt)
+ {
+ unchecked
+ {
+ var x = (uint)seed;
+ x ^= (uint)value * 0x9E37_79B9u;
+ x ^= (uint)salt * 0x85EB_CA6Bu;
+ x ^= x >> 16;
+ x *= 0x7FEB_352Du;
+ x ^= x >> 15;
+ x *= 0x846C_A68Bu;
+ x ^= x >> 16;
+ return x;
+ }
+ }
+}
+
+public sealed record BenchmarkResult(
+ string Scenario,
+ string Engine,
+ long TotalRows,
+ TimeSpan Duration,
+ long? RowsAffected = null,
+ long? RowsRead = null,
+ long? AllocatedBytes = null)
+{
+ public double OperationsPerSecond =>
+ (RowsAffected ?? RowsRead ?? TotalRows) / Duration.TotalSeconds;
+}
+
+public sealed record RunOptions(
+ ScenarioSize Size,
+ int Iterations,
+ int WarmupIterations,
+ int DataSeed,
+ string? JsonPath,
+ string? OutputDirectory,
+ bool AlternateOrder,
+ bool UseNativeDecentDbHotPaths,
+ EngineSelection EngineSelection,
+ DurabilityProfile DurabilityProfile,
+ bool CollectAllocations)
+{
+ public static RunOptions Parse(string[] args)
+ {
+ var size = ScenarioSize.Small;
+ var iterations = 1;
+ var warmupIterations = 0;
+ var dataSeed = 42;
+ string? jsonPath = null;
+ string? outputDirectory = null;
+ var alternateOrder = true;
+ var useNativeDecentDbHotPaths = false;
+ var engineSelection = EngineSelection.All;
+ var durabilityProfile = DurabilityProfile.Relaxed;
+ var collectAllocations = false;
+
+ for (var i = 0; i < args.Length; i++)
+ {
+ var arg = args[i];
+ if (arg.Equals("--size", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
+ {
+ size = ParseSize(args[++i]);
+ }
+ else if (arg.Equals("--iterations", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
+ {
+ iterations = Math.Max(1, int.Parse(args[++i]));
+ }
+ else if (arg.Equals("--warmup-iterations", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
+ {
+ warmupIterations = Math.Max(0, int.Parse(args[++i]));
+ }
+ else if (arg.Equals("--seed", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
+ {
+ dataSeed = int.Parse(args[++i]);
+ }
+ else if (arg.Equals("--json", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
+ {
+ jsonPath = args[++i];
+ }
+ else if (arg.Equals("--out-dir", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
+ {
+ outputDirectory = args[++i];
+ }
+ else if (arg.Equals("--no-alternate-order", StringComparison.OrdinalIgnoreCase))
+ {
+ alternateOrder = false;
+ }
+ else if (arg.Equals("--decentdb-native-hot-paths", StringComparison.OrdinalIgnoreCase))
+ {
+ useNativeDecentDbHotPaths = true;
+ }
+ else if (arg.Equals("--collect-allocations", StringComparison.OrdinalIgnoreCase))
+ {
+ collectAllocations = true;
+ }
+ else if (arg.Equals("--no-decentdb-native-hot-paths", StringComparison.OrdinalIgnoreCase))
+ {
+ useNativeDecentDbHotPaths = false;
+ }
+ else if (arg.Equals("--no-collect-allocations", StringComparison.OrdinalIgnoreCase))
+ {
+ collectAllocations = false;
+ }
+ else if (arg.Equals("--engines", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
+ {
+ engineSelection = ParseEngineSelection(args[++i]);
+ }
+ else if (arg.Equals("--decentdb-relaxed", StringComparison.OrdinalIgnoreCase))
+ {
+ durabilityProfile = DurabilityProfile.Relaxed;
+ }
+ else if (arg.Equals("--decentdb-durable", StringComparison.OrdinalIgnoreCase))
+ {
+ durabilityProfile = DurabilityProfile.Durable;
+ }
+ else if (arg.Equals("--durability", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
+ {
+ if (!Enum.TryParse(args[++i], ignoreCase: true, out durabilityProfile))
+ {
+ throw new ArgumentException(
+ $"Unknown durability profile '{args[i - 1]}'. Use relaxed or durable.");
+ }
+ }
+ else if (!arg.StartsWith("--", StringComparison.Ordinal) && i == 0)
+ {
+ size = ParseSize(arg);
+ }
+ else
+ {
+ throw new ArgumentException(
+ $"Unknown argument '{arg}'. Use --size --iterations --warmup-iterations --seed --json --out-dir --durability --engines [--decentdb-relaxed|--decentdb-durable] [--no-alternate-order] [--decentdb-native-hot-paths|--no-decentdb-native-hot-paths] [--collect-allocations|--no-collect-allocations].");
+ }
+ }
+
+ return new RunOptions(size, iterations, warmupIterations, dataSeed, jsonPath, outputDirectory, alternateOrder, useNativeDecentDbHotPaths, engineSelection, durabilityProfile, collectAllocations);
+ }
+
+ private static ScenarioSize ParseSize(string value)
+ {
+ if (!Enum.TryParse(value, true, out var size))
+ {
+ throw new ArgumentException($"Unknown scenario '{value}'. Use: Tiny, Small, Medium, Large, Jumbo.");
+ }
+
+ return size;
+ }
+
+ private static EngineSelection ParseEngineSelection(string value)
+ {
+ return value.ToLowerInvariant() switch
+ {
+ "all" => EngineSelection.All,
+ "decentdb" => EngineSelection.DecentDB,
+ "sqlite" => EngineSelection.SQLite,
+ _ => throw new ArgumentException(
+ $"Unknown engine selection '{value}'. Use all, decentdb, or sqlite.")
+ };
+ }
+}
+
+public sealed record BenchmarkManifest(
+ string Benchmark,
+ string RunId,
+ string ScenarioSize,
+ long ApproximateRows,
+ int Iterations,
+ int WarmupIterations,
+ bool AlternateOrder,
+ int DataSeed,
+ DateTimeOffset StartedAtUtc,
+ DateTimeOffset FinishedAtUtc,
+ string OutputDirectory,
+ string MachineName,
+ int ProcessorCount,
+ string DotNetVersion,
+ string OsDescription,
+ string OsArchitecture,
+ string ProcessArchitecture,
+ string DecentDbAdoNetAssemblyVersion,
+ string DecentDbEngineVersion,
+ uint DecentDbAbiVersion,
+ string DurabilityProfile,
+ bool UseNativeDecentDbHotPaths,
+ bool NativeDecentDbHotPathsActive,
+ bool CollectAllocations,
+ string SQLiteProviderVersion,
+ string SQLiteNativeVersion,
+ [property: JsonPropertyName("engine_order")]
+ IReadOnlyList EngineOrder,
+ string DotnetSdkVersion,
+ string DecentDbAdoNetVersion,
+ string DecentDbPackage);
+
+public sealed record BenchmarkScenarioResult(
+ string RunId,
+ string Phase,
+ int Iteration,
+ string Engine,
+ int EngineOrder,
+ string Scenario,
+ long TotalRows,
+ double DurationMs,
+ long? RowsAffected,
+ long? RowsRead,
+ double OperationsPerSecond,
+ string DatabasePath,
+ long DatabaseBytes,
+ long? AllocatedBytes);
+
+public sealed record BenchmarkSummary(
+ string Scenario,
+ string Engine,
+ int Iterations,
+ double MeanMs,
+ double MedianMs,
+ double P95Ms,
+ double MinMs,
+ double MaxMs,
+ double StdDevMs,
+ double MeanOperationsPerSecond,
+ double? MeanAllocatedBytes);
+
+public sealed record BenchmarkJsonOutput(
+ BenchmarkManifest Manifest,
+ IReadOnlyList Results,
+ IReadOnlyList Summary);
+
+public interface IDatabaseProvider : IAsyncDisposable
+{
+ string EngineName { get; }
+ string DatabasePath { get; }
+ DbConnection Connection { get; }
+ Task OpenAsync();
+ Task CloseAsync();
+ Task ExecuteNonQueryAsync(string sql);
+ Task ExecuteNonQueryAsync(string sql, params (string Name, object? Value)[] parameters);
+ Task