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 ExecuteScalarAsync(string sql); + Task ExecuteReaderAsync(string sql, params (string Name, object? Value)[] parameters); + DbTransaction BeginTransaction(); + Task CheckpointAsync(); + string ExplainPlan(string sql); +} + +public sealed class DecentDbProvider : IDatabaseProvider +{ + private const string RelaxedNativeOptions = "cache_size=128MB;retain_paged_row_sources_after_commit=true;paged_row_storage=false;wal_autocheckpoint=0;process_coordination=single_process_unsafe;wal_sync_mode=async_commit:10;plan_cache_max_bytes=2097152"; + private const string DurableNativeOptions = "cache_size=128MB;retain_paged_row_sources_after_commit=true;paged_row_storage=false;wal_autocheckpoint=0;process_coordination=single_process_unsafe;plan_cache_max_bytes=2097152"; + + private readonly string _path; + private readonly DurabilityProfile _durabilityProfile; + private DecentDBConnection? _conn; + public string EngineName => "DecentDB"; + public DbConnection Connection => _conn ?? throw new InvalidOperationException("Connection is not open"); + public string DatabasePath => _path; + + public DecentDbProvider(string path, DurabilityProfile durabilityProfile) + { + _path = path; + _durabilityProfile = durabilityProfile; + } + + public static string GetNativeOptions(DurabilityProfile durabilityProfile) + { + return durabilityProfile == DurabilityProfile.Relaxed ? RelaxedNativeOptions : DurableNativeOptions; + } + + public async ValueTask DisposeAsync() + { + if (_conn is not null) await _conn.DisposeAsync(); + } + + public Task OpenAsync() + { + var csb = new DecentDBConnectionStringBuilder + { + DataSource = _path, + PerformanceProfile = "embedded_fast", + CacheSize = "128MB", + RetainPagedRowSourcesAfterCommit = true, + PagedRowStorage = false, + WalAutoCheckpoint = "0", + ProcessCoordination = "single_process_unsafe", + CommandTimeout = 600 + }; + // Append raw native options for async-commit durability (SQLite NORMAL-style) and larger plan cache. + var baseCs = csb.ConnectionString.Trim(); + csb.ConnectionString = baseCs + ";" + GetNativeOptions(_durabilityProfile); + _conn = new DecentDBConnection(csb.ConnectionString); + return _conn.OpenAsync(); + } + + public Task CloseAsync() => _conn!.CloseAsync(); + + public async Task ExecuteNonQueryAsync(string sql) + { + using var cmd = _conn!.CreateCommand(); + cmd.CommandText = sql; + await cmd.ExecuteNonQueryAsync(); + } + + public async Task ExecuteNonQueryAsync(string sql, params (string Name, object? Value)[] parameters) + { + using var cmd = _conn!.CreateCommand(); + var (convertedSql, values) = ConvertParameters(sql, parameters); + cmd.CommandText = convertedSql; + for (int i = 0; i < values.Count; i++) + AddParameter(cmd, i + 1, values[i]); + await cmd.ExecuteNonQueryAsync(); + } + + public async Task ExecuteScalarAsync(string sql) + { + using var cmd = _conn!.CreateCommand(); + cmd.CommandText = sql; + var result = await cmd.ExecuteScalarAsync(); + return result ?? DBNull.Value; + } + + public async Task ExecuteReaderAsync(string sql, params (string Name, object? Value)[] parameters) + { + var cmd = _conn!.CreateCommand(); + var (convertedSql, values) = ConvertParameters(sql, parameters); + cmd.CommandText = convertedSql; + for (int i = 0; i < values.Count; i++) + AddParameter(cmd, i + 1, values[i]); + var reader = await cmd.ExecuteReaderAsync(); + return new DataReaderWrapper(reader, cmd); + } + + private static (string Sql, List Values) ConvertParameters(string sql, (string Name, object? Value)[] parameters) + { + var values = new List(); + foreach (var (name, value) in parameters) + { + sql = sql.Replace($":{name}", $"${values.Count + 1}", StringComparison.OrdinalIgnoreCase); + values.Add(value); + } + return (sql, values); + } + + public DbTransaction BeginTransaction() => _conn!.BeginTransaction(); + + public Task CheckpointAsync() + { + _conn!.Checkpoint(); + return Task.CompletedTask; + } + + public string ExplainPlan(string sql) => _conn!.ExplainQuery(sql).Text; + + private static void AddParameter(DbCommand cmd, int index, object? value) + { + var p = cmd.CreateParameter(); + p.ParameterName = $"{index}"; + p.Value = value ?? DBNull.Value; + cmd.Parameters.Add(p); + } +} + +public sealed class SqliteProvider : IDatabaseProvider +{ + private readonly string _path; + private readonly bool _useDurableSync; + private SqliteConnection? _conn; + public string EngineName => "SQLite"; + public string DatabasePath => _path; + public DbConnection Connection => _conn ?? throw new InvalidOperationException("Connection is not open"); + + public SqliteProvider(string path, bool useDurableSync) => (_path, _useDurableSync) = (path, useDurableSync); + + public async ValueTask DisposeAsync() + { + if (_conn is not null) await _conn.DisposeAsync(); + } + + public async Task OpenAsync() + { + var builder = new SqliteConnectionStringBuilder + { + DataSource = _path, + Pooling = false + }; + _conn = new SqliteConnection(builder.ConnectionString); + await _conn.OpenAsync(); + await ExecuteNonQueryAsync("PRAGMA journal_mode = WAL;"); + await ExecuteNonQueryAsync(_useDurableSync ? "PRAGMA synchronous = FULL;" : "PRAGMA synchronous = NORMAL;"); + await ExecuteNonQueryAsync("PRAGMA foreign_keys = ON;"); + await ExecuteNonQueryAsync("PRAGMA cache_size = -65536;"); + await ExecuteNonQueryAsync("PRAGMA temp_store = MEMORY;"); + } + + public Task CloseAsync() => _conn!.CloseAsync(); + + public async Task ExecuteNonQueryAsync(string sql) + { + using var cmd = _conn!.CreateCommand(); + cmd.CommandText = sql; + await cmd.ExecuteNonQueryAsync(); + } + + public async Task ExecuteNonQueryAsync(string sql, params (string Name, object? Value)[] parameters) + { + using var cmd = _conn!.CreateCommand(); + var (convertedSql, values) = ConvertParameters(sql, parameters); + cmd.CommandText = convertedSql; + for (int i = 0; i < values.Count; i++) + AddParameter(cmd, i + 1, values[i]); + await cmd.ExecuteNonQueryAsync(); + } + + public async Task ExecuteScalarAsync(string sql) + { + using var cmd = _conn!.CreateCommand(); + cmd.CommandText = sql; + var result = await cmd.ExecuteScalarAsync(); + return result ?? DBNull.Value; + } + + public async Task ExecuteReaderAsync(string sql, params (string Name, object? Value)[] parameters) + { + var cmd = _conn!.CreateCommand(); + var (convertedSql, values) = ConvertParameters(sql, parameters); + cmd.CommandText = convertedSql; + for (int i = 0; i < values.Count; i++) + AddParameter(cmd, i + 1, values[i]); + var reader = await cmd.ExecuteReaderAsync(); + return new DataReaderWrapper(reader, cmd); + } + + private static (string Sql, List Values) ConvertParameters(string sql, (string Name, object? Value)[] parameters) + { + var values = new List(); + foreach (var (name, value) in parameters) + { + sql = sql.Replace($":{name}", $"${values.Count + 1}", StringComparison.OrdinalIgnoreCase); + values.Add(value); + } + return (sql, values); + } + + public DbTransaction BeginTransaction() => _conn!.BeginTransaction(); + + public Task CheckpointAsync() + { + using var cmd = _conn!.CreateCommand(); + cmd.CommandText = "PRAGMA wal_checkpoint(TRUNCATE);"; + cmd.ExecuteNonQuery(); + return Task.CompletedTask; + } + + public string ExplainPlan(string sql) + { + using var cmd = _conn!.CreateCommand(); + cmd.CommandText = $"EXPLAIN QUERY PLAN {sql}"; + var sb = new StringBuilder(); + using var r = cmd.ExecuteReader(); + while (r.Read()) sb.AppendLine(r.GetString(3)); + return sb.ToString(); + } + + private static void AddParameter(DbCommand cmd, int index, object? value) + { + var p = cmd.CreateParameter(); + p.ParameterName = $"{index}"; + p.Value = value ?? DBNull.Value; + cmd.Parameters.Add(p); + } +} + +public static class Schema +{ + // Hot-loop DML uses ADO.NET parameters and prepared commands in the harness. + + public const string DecentDdl = """ + CREATE TABLE IF NOT EXISTS companies ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + tax_id TEXT UNIQUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + active BOOLEAN NOT NULL DEFAULT TRUE + ); + + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY, + company_id INTEGER NOT NULL REFERENCES companies(id) ON DELETE CASCADE, + email TEXT UNIQUE NOT NULL, + full_name TEXT NOT NULL, + role TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + active BOOLEAN NOT NULL DEFAULT TRUE + ); + CREATE INDEX IF NOT EXISTS idx_users_company ON users(company_id); + CREATE INDEX IF NOT EXISTS idx_users_name_trgm ON users USING trigram(full_name); + + CREATE TABLE IF NOT EXISTS addresses ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + line1 TEXT NOT NULL, + city TEXT NOT NULL, + region TEXT NOT NULL, + postal_code TEXT NOT NULL, + country TEXT NOT NULL, + kind TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_addresses_user ON addresses(user_id); + + CREATE TABLE IF NOT EXISTS invoices ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + company_id INTEGER NOT NULL REFERENCES companies(id) ON DELETE CASCADE, + invoice_number TEXT UNIQUE NOT NULL, + issued_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + due_at TIMESTAMP NOT NULL, + total REAL NOT NULL DEFAULT 0, + paid BOOLEAN NOT NULL DEFAULT FALSE + ); + CREATE INDEX IF NOT EXISTS idx_invoices_user ON invoices(user_id); + CREATE INDEX IF NOT EXISTS idx_invoices_company ON invoices(company_id); + CREATE INDEX IF NOT EXISTS idx_invoices_user_total ON invoices(user_id, total); + CREATE INDEX IF NOT EXISTS idx_invoices_unpaid_total ON invoices(total) WHERE paid = FALSE; + CREATE INDEX IF NOT EXISTS idx_invoices_paid_total ON invoices(paid, total); + CREATE INDEX IF NOT EXISTS idx_invoices_unpaid_due ON invoices(due_at) INCLUDE (user_id, invoice_number, total) WHERE paid = FALSE; + + CREATE TABLE IF NOT EXISTS company_revenue ( + company_id INTEGER PRIMARY KEY REFERENCES companies(id) ON DELETE CASCADE, + user_count INTEGER NOT NULL DEFAULT 0, + revenue REAL NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS invoice_items ( + id INTEGER PRIMARY KEY, + invoice_id INTEGER NOT NULL REFERENCES invoices(id) ON DELETE CASCADE, + sku TEXT NOT NULL, + description TEXT NOT NULL, + quantity REAL NOT NULL, + unit_price REAL NOT NULL, + line_total REAL GENERATED ALWAYS AS (quantity * unit_price) STORED + ); + CREATE INDEX IF NOT EXISTS idx_items_invoice ON invoice_items(invoice_id); + + CREATE OR REPLACE VIEW v_unpaid_invoices AS + SELECT i.id, i.invoice_number, u.full_name, u.email, i.total, i.due_at + FROM invoices i + JOIN users u ON u.id = i.user_id + WHERE i.paid = FALSE; + """; + + public const string SqliteDdl = """ + CREATE TABLE IF NOT EXISTS companies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + tax_id TEXT UNIQUE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + active INTEGER NOT NULL DEFAULT 1 + ); + + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + company_id INTEGER NOT NULL REFERENCES companies(id) ON DELETE CASCADE, + email TEXT UNIQUE NOT NULL, + full_name TEXT NOT NULL, + role TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + active INTEGER NOT NULL DEFAULT 1 + ); + CREATE INDEX IF NOT EXISTS idx_users_company ON users(company_id); + + CREATE TABLE IF NOT EXISTS addresses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + line1 TEXT NOT NULL, + city TEXT NOT NULL, + region TEXT NOT NULL, + postal_code TEXT NOT NULL, + country TEXT NOT NULL, + kind TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_addresses_user ON addresses(user_id); + + CREATE TABLE IF NOT EXISTS invoices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + company_id INTEGER NOT NULL REFERENCES companies(id) ON DELETE CASCADE, + invoice_number TEXT UNIQUE NOT NULL, + issued_at TEXT NOT NULL DEFAULT (datetime('now')), + due_at TEXT NOT NULL, + total REAL NOT NULL DEFAULT 0, + paid INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_invoices_user ON invoices(user_id); + CREATE INDEX IF NOT EXISTS idx_invoices_company ON invoices(company_id); + CREATE INDEX IF NOT EXISTS idx_invoices_user_total ON invoices(user_id, total); + CREATE INDEX IF NOT EXISTS idx_invoices_unpaid_total ON invoices(total) WHERE paid = 0; + CREATE INDEX IF NOT EXISTS idx_invoices_paid_total ON invoices(paid, total); + CREATE INDEX IF NOT EXISTS idx_invoices_unpaid_due ON invoices(due_at, user_id, invoice_number, total) WHERE paid = 0; + + CREATE TABLE IF NOT EXISTS company_revenue ( + company_id INTEGER PRIMARY KEY REFERENCES companies(id) ON DELETE CASCADE, + user_count INTEGER NOT NULL DEFAULT 0, + revenue REAL NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS invoice_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + invoice_id INTEGER NOT NULL REFERENCES invoices(id) ON DELETE CASCADE, + sku TEXT NOT NULL, + description TEXT NOT NULL, + quantity REAL NOT NULL, + unit_price REAL NOT NULL, + line_total REAL GENERATED ALWAYS AS (quantity * unit_price) STORED + ); + CREATE INDEX IF NOT EXISTS idx_items_invoice ON invoice_items(invoice_id); + + CREATE VIEW IF NOT EXISTS v_unpaid_invoices AS + SELECT i.id, i.invoice_number, u.full_name, u.email, i.total, i.due_at + FROM invoices i + JOIN users u ON u.id = i.user_id + WHERE i.paid = 0; + """; +} + + +public sealed class DataReaderWrapper : IDataReader +{ + private readonly IDataReader _reader; + private readonly DbCommand _command; + public DataReaderWrapper(IDataReader reader, DbCommand command) + { + _reader = reader; + _command = command; + } + public bool Read() => _reader.Read(); + public int FieldCount => _reader.FieldCount; + public object this[int i] => _reader[i]; + public object this[string name] => _reader[name]; + public void Close() { _reader.Close(); } + public void Dispose() { _reader.Dispose(); _command.Dispose(); } + public bool IsClosed => _reader.IsClosed; + public string GetName(int i) => _reader.GetName(i); + public int GetOrdinal(string name) => _reader.GetOrdinal(name); + public bool GetBoolean(int i) => _reader.GetBoolean(i); + public byte GetByte(int i) => _reader.GetByte(i); + public long GetBytes(int i, long fieldOffset, byte[]? buffer, int bufferoffset, int length) => _reader.GetBytes(i, fieldOffset, buffer, bufferoffset, length); + public char GetChar(int i) => _reader.GetChar(i); + public long GetChars(int i, long fieldoffset, char[]? buffer, int bufferoffset, int length) => _reader.GetChars(i, fieldoffset, buffer, bufferoffset, length); + public IDataReader GetData(int i) => _reader.GetData(i); + public string GetDataTypeName(int i) => _reader.GetDataTypeName(i); + public DateTime GetDateTime(int i) => _reader.GetDateTime(i); + public decimal GetDecimal(int i) => _reader.GetDecimal(i); + public double GetDouble(int i) => _reader.GetDouble(i); + public Type GetFieldType(int i) => _reader.GetFieldType(i); + public float GetFloat(int i) => _reader.GetFloat(i); + public Guid GetGuid(int i) => _reader.GetGuid(i); + public short GetInt16(int i) => _reader.GetInt16(i); + public int GetInt32(int i) => _reader.GetInt32(i); + public long GetInt64(int i) => _reader.GetInt64(i); + public string GetString(int i) => _reader.GetString(i); + public object GetValue(int i) => _reader.GetValue(i); + public int GetValues(object[] values) => _reader.GetValues(values); + public bool IsDBNull(int i) => _reader.IsDBNull(i); + public DataTable? GetSchemaTable() => _reader.GetSchemaTable(); + public int Depth => _reader.Depth; + public int RecordsAffected => _reader.RecordsAffected; + public bool NextResult() => _reader.NextResult(); +} + +internal sealed class DecentDbNativeHotPathRunner : IDisposable +{ + private readonly global::DecentDB.Native.DecentDB _db; + private PreparedStatement? _pointReadStatement; + private PreparedStatement? _updatePaidStatement; + private PreparedStatement? _verifyPaidStatement; + private PreparedStatement? _windowQueryStatement; + private PreparedStatement? _deleteCascadeStatement; + + private const string PointReadSql = "SELECT id, email, full_name FROM users WHERE id = $1;"; + private const string UpdatePaidSql = "UPDATE invoices SET paid = TRUE WHERE paid = FALSE AND total < $1;"; + private const string VerifyPaidSql = "SELECT COUNT(*) FROM invoices WHERE paid = TRUE AND total < $1;"; + private const string WindowQuerySql = "SELECT user_id, invoice_number, total, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY total DESC) AS rn, RANK() OVER (PARTITION BY user_id ORDER BY total DESC) AS rnk FROM invoices;"; + private const string DeleteCascadeSql = "DELETE FROM companies WHERE id = $1;"; + + public DecentDbNativeHotPathRunner(string databasePath, string nativeOptions) + { + _db = new global::DecentDB.Native.DecentDB(databasePath, nativeOptions); + } + + public void Dispose() + { + _pointReadStatement?.Dispose(); + _updatePaidStatement?.Dispose(); + _verifyPaidStatement?.Dispose(); + _windowQueryStatement?.Dispose(); + _deleteCascadeStatement?.Dispose(); + _db.Dispose(); + } + + public long RunPointReadScenario(IReadOnlyList pointReadUserIds, int lookups) + { + var stmt = _pointReadStatement ??= _db.Prepare(PointReadSql); + if (pointReadUserIds.Count == 0) + { + throw new InvalidOperationException("Point-read benchmark requires at least one existing user id."); + } + + long checksum = 0; + long rows = 0; + _db.BeginTransaction(); + try + { + for (var i = 0; i < lookups; i++) + { + var rc = ExecuteQueryAndReset(stmt, pointReadUserIds[i % pointReadUserIds.Count]); + if (rc == 1) + { + rows++; + checksum += stmt.GetInt64(0); + checksum += stmt.GetText(1).Length; + checksum += stmt.GetText(2).Length; + } + else if (rc < 0) + { + throw new DecentDBException(stmt.LastErrorCode, stmt.LastErrorMessage, PointReadSql); + } + } + + _db.CommitTransaction(); + if (checksum == long.MinValue) + { + throw new InvalidOperationException("Unreachable checksum guard."); + } + + return rows; + } + catch + { + if (_db.InTransaction) + { + _db.RollbackTransaction(); + } + + throw; + } + } + + public long RunUpdatePaidScenario(double max) + { + var update = _updatePaidStatement ??= _db.Prepare(UpdatePaidSql); + var verify = _verifyPaidStatement ??= _db.Prepare(VerifyPaidSql); + + _db.BeginTransaction(); + try + { + update.BindFloat64(1, max); + var affected = update.StepRowsAffected(); + update.Reset(); + + ExecuteScalarCount(verify, max); + _db.CommitTransaction(); + return affected; + } + catch + { + if (_db.InTransaction) + { + _db.RollbackTransaction(); + } + + throw; + } + } + + public long RunWindowScenario() + { + var stmt = _windowQueryStatement ??= _db.Prepare(WindowQuerySql); + var rows = 0L; + + try + { + while (true) + { + var rc = stmt.Step(); + if (rc < 0) + { + throw new DecentDBException(stmt.LastErrorCode, stmt.LastErrorMessage, WindowQuerySql); + } + + if (rc == 0) + { + break; + } + + rows++; + } + + return rows; + } + finally + { + stmt.Reset(); + } + } + + public long RunDeleteCascadeScenario(IReadOnlyList companyIds) + { + var stmt = _deleteCascadeStatement ??= _db.Prepare(DeleteCascadeSql); + var totalDeleted = 0L; + + _db.BeginTransaction(); + try + { + for (var i = 0; i < companyIds.Count; i++) + { + var rows = executeDml(stmt, companyIds[i]); + totalDeleted += rows; + } + + _db.CommitTransaction(); + return totalDeleted; + } + catch + { + if (_db.InTransaction) + { + _db.RollbackTransaction(); + } + + throw; + } + } + + private static void ExecuteScalarCount(PreparedStatement statement, double max) + { + statement.BindFloat64(1, max); + try + { + var rc = statement.Step(); + if (rc < 0) + { + throw new DecentDBException(statement.LastErrorCode, statement.LastErrorMessage, VerifyPaidSql); + } + + if (rc == 0) + { + throw new DecentDBException(statement.LastErrorCode, statement.LastErrorMessage, VerifyPaidSql); + } + + _ = statement.GetInt64(0); + } + finally + { + statement.Reset(); + } + } + + private static long executeDml(PreparedStatement statement, long id) + { + var rows = statement.BindInt64(1, id).StepRowsAffected(); + statement.Reset(); + return rows; + } + + private static long ExecuteQueryAndReset(PreparedStatement statement, int userId) + { + statement.BindInt64(1, userId); + var result = statement.Step(); + statement.Reset(); + return result; + } +} + +public sealed class BenchmarkHarness +{ + private const int DecentDbBatchRows = 2048; + private const int DecentDbTextHeavyBatchRows = 1024; + private const int MinimumScaledTinyOperations = 50_000; + private const int MinimumScaledPointReadLookups = 50_000; + private static readonly byte[] DecentDbCompanyBatchSignature = Encoding.ASCII.GetBytes("itt\0"); + private static readonly byte[] DecentDbUserBatchSignature = Encoding.ASCII.GetBytes("iittt\0"); + private static readonly byte[] DecentDbAddressBatchSignature = Encoding.ASCII.GetBytes("iittttt\0"); + private static readonly byte[] DecentDbInvoiceBatchSignature = Encoding.ASCII.GetBytes("iiitttfb\0"); + private static readonly byte[] DecentDbInvoiceItemBatchSignature = Encoding.ASCII.GetBytes("itff\0"); + + private readonly IDatabaseProvider _db; + private readonly DatabaseEngine _engine; + private readonly WorkloadProfile _profile; + private readonly WorkloadData _data; + private readonly string? _explainDirectory; + private readonly DurabilityProfile _durabilityProfile; + private readonly List _results = new(); + private readonly List _companyIds = new(); + private readonly List _userIds = new(); + private readonly List _invoiceIds = new(); + private readonly bool _useNativeDecentDbHotPaths; + private readonly bool _collectAllocations; + private readonly DecentDbNativeHotPathRunner? _nativeRunner; + private readonly bool _nativeRunnerActive; + + public bool NativeDecentDbHotPathsActive => _nativeRunnerActive; + + public BenchmarkHarness( + IDatabaseProvider db, + DatabaseEngine engine, + WorkloadProfile profile, + WorkloadData data, + string? explainDirectory, + DurabilityProfile durabilityProfile, + bool useNativeDecentDbHotPaths, + bool collectAllocations) + { + _db = db; + _engine = engine; + _profile = profile; + _data = data; + _explainDirectory = explainDirectory; + _durabilityProfile = durabilityProfile; + _useNativeDecentDbHotPaths = useNativeDecentDbHotPaths; + _collectAllocations = collectAllocations; + + if (_useNativeDecentDbHotPaths && _db is DecentDbProvider && engine == DatabaseEngine.DecentDB) + { + try + { + _nativeRunner = new DecentDbNativeHotPathRunner(_db.DatabasePath, DecentDbProvider.GetNativeOptions(_durabilityProfile)); + _nativeRunnerActive = true; + } + catch + { + _nativeRunnerActive = false; + } + } + } + + public IReadOnlyList Results => _results; + + private DbCommand CreatePreparedCommand( + DbTransaction? transaction, + string sql, + params (string Name, DbType Type)[] parameters) + { + var command = _db.Connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = sql; + + foreach (var (name, type) in parameters) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.DbType = type; + command.Parameters.Add(parameter); + } + + try + { + command.Prepare(); + } + catch (NotSupportedException) + { + // Some ADO.NET providers treat Prepare as optional. + } + + return command; + } + + private static DbParameter Parameter(DbCommand command, int index) => + (DbParameter)command.Parameters[index]; + + private long ExecuteDecentDbCompanyBatch( + DecentDBConnection connection, + int rowCount, + long[] i64Values, + List textValues) + { + if (rowCount == 0) + { + return 0; + } + + return connection.ExecutePreparedBatchTyped( + "INSERT INTO companies (id, name, tax_id, active) VALUES ($1, $2, $3, TRUE);", + DecentDbCompanyBatchSignature, + rowCount, + i64Values.AsSpan(0, rowCount), + ReadOnlySpan.Empty, + textValues); + } + + private long ExecuteDecentDbUserBatch( + DecentDBConnection connection, + int rowCount, + long[] i64Values, + List textValues) + { + if (rowCount == 0) + { + return 0; + } + + return connection.ExecutePreparedBatchTyped( + "INSERT INTO users (id, company_id, email, full_name, role, active) VALUES ($1, $2, $3, $4, $5, TRUE);", + DecentDbUserBatchSignature, + rowCount, + i64Values.AsSpan(0, rowCount * 2), + ReadOnlySpan.Empty, + textValues); + } + + private long ExecuteDecentDbAddressBatch( + DecentDBConnection connection, + int rowCount, + long[] i64Values, + List textValues) + { + if (rowCount == 0) + { + return 0; + } + + return connection.ExecutePreparedBatchTyped( + "INSERT INTO addresses (id, user_id, line1, city, region, postal_code, country, kind) VALUES ($1, $2, $3, $4, $5, $6, 'US', $7);", + DecentDbAddressBatchSignature, + rowCount, + i64Values.AsSpan(0, rowCount * 2), + ReadOnlySpan.Empty, + textValues); + } + + private long ExecuteDecentDbInvoiceBatch( + DecentDBConnection connection, + int rowCount, + long[] i64Values, + double[] f64Values, + List textValues) + { + if (rowCount == 0) + { + return 0; + } + + return connection.ExecutePreparedBatchTyped( + "INSERT INTO invoices (id, user_id, company_id, invoice_number, issued_at, due_at, total, paid) VALUES ($1, $2, $3, $4, $5, $6, $7, $8);", + DecentDbInvoiceBatchSignature, + rowCount, + i64Values.AsSpan(0, rowCount * 4), + f64Values.AsSpan(0, rowCount), + textValues); + } + + private long ExecuteDecentDbInvoiceItemBatch( + DecentDBConnection connection, + int rowCount, + long[] i64Values, + double[] f64Values, + List textValues) + { + if (rowCount == 0) + { + return 0; + } + + return connection.ExecutePreparedBatchTyped( + "INSERT INTO invoice_items (invoice_id, sku, description, quantity, unit_price) VALUES ($1, $2, 'Benchmark item', $3, $4);", + DecentDbInvoiceItemBatchSignature, + rowCount, + i64Values.AsSpan(0, rowCount), + f64Values.AsSpan(0, rowCount * 2), + textValues); + } + + private static int RepeatCountForAtLeast(int rowsPerRepeat, int minimumRows) + { + if (rowsPerRepeat <= 0) + { + return 1; + } + + return Math.Max(1, (minimumRows + rowsPerRepeat - 1) / rowsPerRepeat); + } + + public async Task RunAsync() + { + try + { + Console.WriteLine($"\n--- Running {_db.EngineName} benchmark ---"); + await InitializeSchemaAsync(); + + var companyInsertRepeats = RepeatCountForAtLeast(_profile.Companies, MinimumScaledTinyOperations); + + _results.Add(await RunScenarioAsync("01. Bulk Insert Companies", async () => + { + if (_db.Connection is DecentDBConnection decentConnection) + { + long count = 0; + var i64Values = new long[DecentDbBatchRows]; + var textValues = new List(DecentDbBatchRows * 2); + + void InsertDecentCompanyRows(int repeats, int idOffset, bool recordCompanyIds) + { + for (int repeat = 0; repeat < repeats; repeat++) + { + var batchRows = 0; + + for (int i = 1; i <= _profile.Companies; i++) + { + var rowId = idOffset + (repeat * _profile.Companies) + i; + i64Values[batchRows] = rowId; + textValues.Add(Encoding.UTF8.GetBytes(WorkloadDataFactory.CompanyName(rowId, _data.Seed))); + textValues.Add(Encoding.UTF8.GetBytes(recordCompanyIds ? $"TAX-{i:D9}" : $"TAX-S01-{rowId:D12}")); + if (recordCompanyIds) + { + _companyIds.Add(i); + } + + count++; + batchRows++; + + if (batchRows == DecentDbBatchRows) + { + ExecuteDecentDbCompanyBatch(decentConnection, batchRows, i64Values, textValues); + batchRows = 0; + textValues.Clear(); + } + } + + ExecuteDecentDbCompanyBatch(decentConnection, batchRows, i64Values, textValues); + textValues.Clear(); + } + } + + using (var setupTx = _db.BeginTransaction()) + { + InsertDecentCompanyRows(repeats: 1, idOffset: 0, recordCompanyIds: true); + setupTx.Commit(); + } + + if (companyInsertRepeats > 1) + { + using var scaleTx = _db.BeginTransaction(); + InsertDecentCompanyRows( + repeats: companyInsertRepeats - 1, + idOffset: _profile.Companies, + recordCompanyIds: false); + scaleTx.Rollback(); + } + + return count; + } + + long fallbackCount = 0; + + async Task InsertCompanyRowsAsync(DbTransaction tx, int repeats, int idOffset, bool recordCompanyIds) + { + if (repeats == 0) + { + return; + } + + using var cmd = CreatePreparedCommand( + tx, + "INSERT INTO companies (id, name, tax_id, active) VALUES (@id, @name, @tax, TRUE);", + ("@id", DbType.Int64), + ("@name", DbType.String), + ("@tax", DbType.String)); + var idParam = Parameter(cmd, 0); + var nameParam = Parameter(cmd, 1); + var taxParam = Parameter(cmd, 2); + + for (int repeat = 0; repeat < repeats; repeat++) + { + for (int i = 1; i <= _profile.Companies; i++) + { + var rowId = idOffset + (repeat * _profile.Companies) + i; + idParam.Value = rowId; + nameParam.Value = WorkloadDataFactory.CompanyName(rowId, _data.Seed); + taxParam.Value = recordCompanyIds ? $"TAX-{i:D9}" : $"TAX-S01-{rowId:D12}"; + await cmd.ExecuteNonQueryAsync(); + if (recordCompanyIds) + { + _companyIds.Add(i); + } + + fallbackCount++; + } + } + } + + using (var setupTx = _db.BeginTransaction()) + { + await InsertCompanyRowsAsync(setupTx, repeats: 1, idOffset: 0, recordCompanyIds: true); + setupTx.Commit(); + } + + if (companyInsertRepeats > 1) + { + using var scaleTx = _db.BeginTransaction(); + await InsertCompanyRowsAsync( + scaleTx, + repeats: companyInsertRepeats - 1, + idOffset: _profile.Companies, + recordCompanyIds: false); + scaleTx.Rollback(); + } + + return fallbackCount; + })); + + _results.Add(await RunScenarioAsync("02. Bulk Insert Users", async () => + { + if (_db.Connection is DecentDBConnection decentConnection) + { + int count = 0; + int batchRows = 0; + var i64Values = new long[DecentDbBatchRows * 2]; + var textValues = new List(DecentDbBatchRows * 3); + using var bulkTx = _db.BeginTransaction(); + + for (int c = 0; c < _profile.Companies; c++) + { + int companyId = _companyIds[c % _companyIds.Count]; + for (int u = 0; u < _profile.UsersPerCompany; u++) + { + int id = count + 1; + var i64Offset = batchRows * 2; + i64Values[i64Offset] = id; + i64Values[i64Offset + 1] = companyId; + textValues.Add(Encoding.UTF8.GetBytes($"user{id:D9}@bench.local")); + textValues.Add(Encoding.UTF8.GetBytes($"User {id:D9}")); + textValues.Add(Encoding.UTF8.GetBytes(WorkloadDataFactory.Role(id, _data.Seed))); + _userIds.Add(id); + count++; + batchRows++; + + if (batchRows == DecentDbBatchRows) + { + ExecuteDecentDbUserBatch(decentConnection, batchRows, i64Values, textValues); + batchRows = 0; + textValues.Clear(); + } + } + } + + ExecuteDecentDbUserBatch(decentConnection, batchRows, i64Values, textValues); + bulkTx.Commit(); + return count; + } + + int fallbackCount = 0; + using var tx = _db.BeginTransaction(); + using var cmd = CreatePreparedCommand( + tx, + "INSERT INTO users (company_id, email, full_name, role, active) VALUES (@cid, @email, @name, @role, TRUE);", + ("@cid", DbType.Int64), + ("@email", DbType.String), + ("@name", DbType.String), + ("@role", DbType.String)); + var companyParam = Parameter(cmd, 0); + var emailParam = Parameter(cmd, 1); + var nameParam = Parameter(cmd, 2); + var roleParam = Parameter(cmd, 3); + + for (int c = 0; c < _profile.Companies; c++) + { + int companyId = _companyIds[c % _companyIds.Count]; + for (int u = 0; u < _profile.UsersPerCompany; u++) + { + int id = fallbackCount + 1; + companyParam.Value = companyId; + emailParam.Value = $"user{id:D9}@bench.local"; + nameParam.Value = $"User {id:D9}"; + roleParam.Value = WorkloadDataFactory.Role(id, _data.Seed); + await cmd.ExecuteNonQueryAsync(); + _userIds.Add(id); + fallbackCount++; + } + } + tx.Commit(); + return fallbackCount; + }, rowsAffected: _profile.Companies * _profile.UsersPerCompany)); + + _results.Add(await RunScenarioAsync("03. Bulk Insert Addresses", async () => + { + if (_db.Connection is DecentDBConnection decentConnection) + { + int count = 0; + int batchRows = 0; + var i64Values = new long[DecentDbTextHeavyBatchRows * 2]; + var textValues = new List(DecentDbTextHeavyBatchRows * 5); + using var bulkTx = _db.BeginTransaction(); + + foreach (var userId in _userIds) + { + for (int a = 0; a < _profile.AddressesPerUser; a++) + { + int id = count + 1; + var i64Offset = batchRows * 2; + i64Values[i64Offset] = id; + i64Values[i64Offset + 1] = userId; + textValues.Add(Encoding.UTF8.GetBytes($"{id} Benchmark Blvd")); + textValues.Add(Encoding.UTF8.GetBytes(WorkloadDataFactory.City(userId, a, _data.Seed))); + textValues.Add(Encoding.UTF8.GetBytes(WorkloadDataFactory.Region(userId, a, _data.Seed))); + textValues.Add(Encoding.UTF8.GetBytes($"{10000 + (count % 90000)}")); + textValues.Add(Encoding.UTF8.GetBytes(a == 0 ? "billing" : "shipping")); + count++; + batchRows++; + + if (batchRows == DecentDbTextHeavyBatchRows) + { + ExecuteDecentDbAddressBatch(decentConnection, batchRows, i64Values, textValues); + batchRows = 0; + textValues.Clear(); + } + } + } + + ExecuteDecentDbAddressBatch(decentConnection, batchRows, i64Values, textValues); + bulkTx.Commit(); + return count; + } + + int fallbackCount = 0; + using var tx = _db.BeginTransaction(); + using var cmd = CreatePreparedCommand( + tx, + "INSERT INTO addresses (user_id, line1, city, region, postal_code, country, kind) VALUES (@uid, @line1, @city, @region, @postal, @country, @kind);", + ("@uid", DbType.Int64), + ("@line1", DbType.String), + ("@city", DbType.String), + ("@region", DbType.String), + ("@postal", DbType.String), + ("@country", DbType.String), + ("@kind", DbType.String)); + var userParam = Parameter(cmd, 0); + var lineParam = Parameter(cmd, 1); + var cityParam = Parameter(cmd, 2); + var regionParam = Parameter(cmd, 3); + var postalParam = Parameter(cmd, 4); + var countryParam = Parameter(cmd, 5); + var kindParam = Parameter(cmd, 6); + countryParam.Value = "US"; + + foreach (var userId in _userIds) + { + for (int a = 0; a < _profile.AddressesPerUser; a++) + { + userParam.Value = userId; + lineParam.Value = $"{fallbackCount + 1} Benchmark Blvd"; + cityParam.Value = WorkloadDataFactory.City(userId, a, _data.Seed); + regionParam.Value = WorkloadDataFactory.Region(userId, a, _data.Seed); + postalParam.Value = $"{10000 + (fallbackCount % 90000)}"; + kindParam.Value = a == 0 ? "billing" : "shipping"; + await cmd.ExecuteNonQueryAsync(); + fallbackCount++; + } + } + tx.Commit(); + return fallbackCount; + }, rowsAffected: _userIds.Count * _profile.AddressesPerUser)); + + _results.Add(await RunScenarioAsync("04. Bulk Insert Invoices", async () => + { + if (_db.Connection is DecentDBConnection decentConnection) + { + int bulkCount = 0; + int batchRows = 0; + var i64Values = new long[DecentDbBatchRows * 4]; + var f64Values = new double[DecentDbBatchRows]; + var textValues = new List(DecentDbBatchRows * 3); + using var bulkTx = _db.BeginTransaction(); + + foreach (var userId in _userIds) + { + int companyId = ((userId - 1) / _profile.UsersPerCompany) + 1; + for (int inv = 0; inv < _profile.InvoicesPerUser; inv++) + { + int id = bulkCount + 1; + var issued = WorkloadDataFactory.IssuedDateUtc(id, _data.BaseUtc, _data.Seed); + var due = WorkloadDataFactory.DueDateUtc(id, _data.BaseUtc, _data.Seed); + var i64Offset = batchRows * 4; + i64Values[i64Offset] = id; + i64Values[i64Offset + 1] = userId; + i64Values[i64Offset + 2] = companyId; + i64Values[i64Offset + 3] = WorkloadDataFactory.InvoicePaid(id, _data.Seed) ? 1 : 0; + f64Values[batchRows] = WorkloadDataFactory.InvoiceTotal(id, _data.Seed); + textValues.Add(Encoding.UTF8.GetBytes($"INV-{id:D12}")); + textValues.Add(Encoding.UTF8.GetBytes(issued.ToString("O"))); + textValues.Add(Encoding.UTF8.GetBytes(due.ToString("O"))); + _invoiceIds.Add(id); + bulkCount++; + batchRows++; + + if (batchRows == DecentDbBatchRows) + { + ExecuteDecentDbInvoiceBatch(decentConnection, batchRows, i64Values, f64Values, textValues); + batchRows = 0; + textValues.Clear(); + } + } + } + + ExecuteDecentDbInvoiceBatch(decentConnection, batchRows, i64Values, f64Values, textValues); + bulkTx.Commit(); + return bulkCount; + } + + int count = 0; + using var tx = _db.BeginTransaction(); + using var cmd = CreatePreparedCommand( + tx, + "INSERT INTO invoices (id, user_id, company_id, invoice_number, issued_at, due_at, total, paid) VALUES (@id, @uid, @cid, @num, @issued, @due, @total, @paid);", + ("@id", DbType.Int64), + ("@uid", DbType.Int64), + ("@cid", DbType.Int64), + ("@num", DbType.String), + ("@issued", DbType.String), + ("@due", DbType.String), + ("@total", DbType.Double), + ("@paid", DbType.Boolean)); + var idParam = Parameter(cmd, 0); + var userParam = Parameter(cmd, 1); + var companyParam = Parameter(cmd, 2); + var numberParam = Parameter(cmd, 3); + var issuedParam = Parameter(cmd, 4); + var dueParam = Parameter(cmd, 5); + var totalParam = Parameter(cmd, 6); + var paidParam = Parameter(cmd, 7); + + foreach (var userId in _userIds) + { + int companyId = ((userId - 1) / _profile.UsersPerCompany) + 1; + for (int inv = 0; inv < _profile.InvoicesPerUser; inv++) + { + int id = count + 1; + var issued = WorkloadDataFactory.IssuedDateUtc(id, _data.BaseUtc, _data.Seed); + var due = WorkloadDataFactory.DueDateUtc(id, _data.BaseUtc, _data.Seed); + idParam.Value = id; + userParam.Value = userId; + companyParam.Value = companyId; + numberParam.Value = $"INV-{id:D12}"; + issuedParam.Value = issued.ToString("O"); + dueParam.Value = due.ToString("O"); + totalParam.Value = WorkloadDataFactory.InvoiceTotal(id, _data.Seed); + paidParam.Value = WorkloadDataFactory.InvoicePaid(id, _data.Seed); + await cmd.ExecuteNonQueryAsync(); + _invoiceIds.Add(id); + count++; + } + } + tx.Commit(); + return count; + }, rowsAffected: _userIds.Count * _profile.InvoicesPerUser)); + + _results.Add(await RunScenarioAsync("05. Bulk Insert Invoice Items", async () => + { + if (_db.Connection is DecentDBConnection decentConnection) + { + int bulkCount = 0; + int batchRows = 0; + var i64Values = new long[DecentDbBatchRows]; + var f64Values = new double[DecentDbBatchRows * 2]; + var textValues = new List(DecentDbBatchRows); + using var bulkTx = _db.BeginTransaction(); + + foreach (var invoiceId in _invoiceIds) + { + for (int it = 0; it < _profile.ItemsPerInvoice; it++) + { + i64Values[batchRows] = invoiceId; + var f64Offset = batchRows * 2; + f64Values[f64Offset] = WorkloadDataFactory.Quantity(invoiceId, it, _data.Seed); + f64Values[f64Offset + 1] = WorkloadDataFactory.UnitPrice(invoiceId, it, _data.Seed); + textValues.Add(Encoding.UTF8.GetBytes(WorkloadDataFactory.Sku(invoiceId, it, _data.Seed))); + bulkCount++; + batchRows++; + + if (batchRows == DecentDbBatchRows) + { + ExecuteDecentDbInvoiceItemBatch(decentConnection, batchRows, i64Values, f64Values, textValues); + batchRows = 0; + textValues.Clear(); + } + } + } + + ExecuteDecentDbInvoiceItemBatch(decentConnection, batchRows, i64Values, f64Values, textValues); + bulkTx.Commit(); + return bulkCount; + } + + int count = 0; + using var tx = _db.BeginTransaction(); + using var cmd = CreatePreparedCommand( + tx, + "INSERT INTO invoice_items (invoice_id, sku, description, quantity, unit_price) VALUES (@inv, @sku, 'Benchmark item', @qty, @price);", + ("@inv", DbType.Int64), + ("@sku", DbType.String), + ("@qty", DbType.Double), + ("@price", DbType.Double)); + var invoiceParam = Parameter(cmd, 0); + var skuParam = Parameter(cmd, 1); + var quantityParam = Parameter(cmd, 2); + var priceParam = Parameter(cmd, 3); + + foreach (var invoiceId in _invoiceIds) + { + for (int it = 0; it < _profile.ItemsPerInvoice; it++) + { + invoiceParam.Value = invoiceId; + skuParam.Value = WorkloadDataFactory.Sku(invoiceId, it, _data.Seed); + quantityParam.Value = WorkloadDataFactory.Quantity(invoiceId, it, _data.Seed); + priceParam.Value = WorkloadDataFactory.UnitPrice(invoiceId, it, _data.Seed); + await cmd.ExecuteNonQueryAsync(); + count++; + } + } + tx.Commit(); + return count; + }, rowsAffected: _invoiceIds.Count * _profile.ItemsPerInvoice)); + + WriteExplainPlans(); + + var pointReadLookups = Math.Max(_profile.PointReadSamples, MinimumScaledPointReadLookups); + _results.Add(await RunScenarioAsync("06. Point Reads (PK lookup)", () => + { + long read = 0; + var pointReadIds = _data.PointReadUserIds; + if (pointReadIds.Count == 0) + { + throw new InvalidOperationException("Point-read benchmark requires at least one existing user id."); + } + + if (_nativeRunnerActive) + { + return Task.FromResult(_nativeRunner!.RunPointReadScenario(pointReadIds, pointReadLookups)); + } + + using var cmd = CreatePreparedCommand( + null, + "SELECT id, email, full_name FROM users WHERE id = @id;", + ("@id", DbType.Int64)); + var idParam = Parameter(cmd, 0); + long checksum = 0; + + for (int i = 0; i < pointReadLookups; i++) + { + idParam.Value = pointReadIds[i % pointReadIds.Count]; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + read++; + checksum += reader.GetInt64(0); + checksum += reader.GetString(1).Length; + checksum += reader.GetString(2).Length; + } + } + if (checksum == long.MinValue) + { + throw new InvalidOperationException("Unreachable checksum guard."); + } + return Task.FromResult(read); + }, rowsRead: pointReadLookups)); + + var joinedAggregateRepeats = 1; + _results.Add(await RunScenarioAsync("07a. Raw Joined Aggregate", () => + { + using var cmd = CreatePreparedCommand( + null, + """ + SELECT c.name, COUNT(DISTINCT u.id) AS user_count, COALESCE(SUM(i.total), 0) AS revenue + FROM companies c + LEFT JOIN users u ON u.company_id = c.id + LEFT JOIN invoices i ON i.user_id = u.id + GROUP BY c.id, c.name + ORDER BY revenue DESC; + """); + long read = 0; + + for (int repeat = 0; repeat < joinedAggregateRepeats; repeat++) + { + using var reader = cmd.ExecuteReader(); + while (reader.Read()) read++; + } + + return Task.FromResult(read); + }, rowsRead: (long)_profile.Companies * joinedAggregateRepeats)); + + _results.Add(await RunScenarioAsync("07b. Build Revenue Summary", async () => + { + await _db.ExecuteNonQueryAsync("DELETE FROM company_revenue;"); + await _db.ExecuteNonQueryAsync(""" + INSERT INTO company_revenue (company_id, user_count, revenue) + SELECT c.id, COUNT(DISTINCT u.id), COALESCE(SUM(i.total), 0) + FROM companies c + JOIN users u ON u.company_id = c.id + LEFT JOIN invoices i ON i.user_id = u.id + GROUP BY c.id; + """); + try + { + await _db.CheckpointAsync(); + } + catch (Exception ex) + { + Console.WriteLine($"[WARN] Checkpoint skipped in scenario 07b due non-fatal error: {ex.Message}"); + } + return _profile.Companies; + }, rowsAffected: _profile.Companies)); + + _results.Add(await RunScenarioAsync("07c. Read Revenue Summary", () => + { + using var cmd = CreatePreparedCommand( + null, + """ + SELECT c.name, cr.user_count, cr.revenue + FROM companies c + JOIN company_revenue cr ON cr.company_id = c.id + ORDER BY cr.revenue DESC; + """); + long read = 0; + + for (int repeat = 0; repeat < joinedAggregateRepeats; repeat++) + { + using var reader = cmd.ExecuteReader(); + while (reader.Read()) read++; + } + + return Task.FromResult(read); + }, rowsRead: (long)_profile.Companies * joinedAggregateRepeats)); + + _results.Add(await RunScenarioAsync("08. Substring Search (LIKE %pattern%)", () => + { + long read = 0; + using var cmd = CreatePreparedCommand( + null, + "SELECT id, full_name FROM users WHERE full_name LIKE @pattern;", + ("@pattern", DbType.String)); + var patternParam = Parameter(cmd, 0); + + for (int i = 0; i < _profile.SearchSamples; i++) + { + patternParam.Value = _data.SearchPatterns[i]; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) read++; + } + return Task.FromResult(read); + }, rowsRead: _profile.SearchSamples)); + + _results.Add(await RunScenarioAsync("09. Update Invoices Paid", () => + { + if (_nativeRunnerActive) + { + return Task.FromResult(_nativeRunner!.RunUpdatePaidScenario(100.0)); + } + + using var tx = _db.BeginTransaction(); + using var update = CreatePreparedCommand( + tx, + "UPDATE invoices SET paid = TRUE WHERE paid = FALSE AND total < @max;", + ("@max", DbType.Double)); + Parameter(update, 0).Value = 100.0; + var affected = update.ExecuteNonQuery(); + + using var verify = CreatePreparedCommand( + tx, + "SELECT COUNT(*) FROM invoices WHERE paid = TRUE AND total < @max;", + ("@max", DbType.Double)); + Parameter(verify, 0).Value = 100.0; + _ = verify.ExecuteScalar(); + tx.Commit(); + return Task.FromResult((long)affected); + }, rowsAffected: null)); + + _results.Add(await RunScenarioAsync("10. Complex Window/Analytic Query", () => + { + if (_nativeRunnerActive) + { + return Task.FromResult(_nativeRunner!.RunWindowScenario()); + } + + using var readerCmd = _db.Connection.CreateCommand(); + readerCmd.CommandText = """ + SELECT user_id, invoice_number, total, + ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY total DESC) AS rn, + RANK() OVER (PARTITION BY user_id ORDER BY total DESC) AS rnk + FROM invoices; + """; + using var reader = readerCmd.ExecuteReader(); + long read = 0; + while (reader.Read()) read++; + return Task.FromResult(read); + }, rowsRead: _invoiceIds.Count)); + + _results.Add(await RunScenarioAsync("11. View Query (Unpaid Invoices)", () => + { + using var readerCmd = _db.Connection.CreateCommand(); + readerCmd.CommandText = "SELECT * FROM v_unpaid_invoices ORDER BY due_at DESC LIMIT 1000;"; + using var reader = readerCmd.ExecuteReader(); + long read = 0; + while (reader.Read()) read++; + return Task.FromResult(read); + }, rowsRead: 1000)); + + _results.Add(await RunScenarioAsync("12. Delete Cascade Test", () => + { + if (_nativeRunnerActive) + { + return Task.FromResult(_nativeRunner!.RunDeleteCascadeScenario(_data.DeleteCompanyIds)); + } + + using var tx = _db.BeginTransaction(); + var ids = _data.DeleteCompanyIds; + var idPlaceholders = string.Join(",", ids.Select((_, i) => $"@id{i}")); + var idParameters = ids.Select((_, i) => ($"@id{i}", DbType.Int64)).ToArray(); + using var cmd = CreatePreparedCommand( + tx, + $"DELETE FROM companies WHERE id IN ({idPlaceholders});", + idParameters); + + for (var i = 0; i < ids.Count; i++) + { + Parameter(cmd, i).Value = ids[i]; + } + + var deleted = (long)cmd.ExecuteNonQuery(); + tx.Commit(); + return Task.FromResult(deleted); + }, rowsAffected: _data.DeleteCompanyIds.Count)); + + await _db.CheckpointAsync(); + } + finally + { + if (_nativeRunnerActive) + { + _nativeRunner?.Dispose(); + } + } + } + + private async Task RunScenarioAsync( + string name, + Func> action, + long? rowsAffected = null, + long? rowsRead = null, + Func? setup = null, + Func? cleanup = null) + { + if (setup is not null) + { + await setup(); + } + + var sw = new Stopwatch(); + long actualRows = 0; + long? allocatedBytes = null; + long startAllocatedBytes = 0; + if (_collectAllocations) + { + startAllocatedBytes = GC.GetTotalAllocatedBytes(precise: false); + } + + try + { + sw.Start(); + actualRows = await action(); + sw.Stop(); + if (_collectAllocations) + { + var endAllocatedBytes = GC.GetTotalAllocatedBytes(precise: false); + allocatedBytes = endAllocatedBytes >= startAllocatedBytes + ? endAllocatedBytes - startAllocatedBytes + : null; + } + } + finally + { + if (sw.IsRunning) + { + sw.Stop(); + } + + if (cleanup is not null) + { + await cleanup(); + } + } + + var result = new BenchmarkResult( + name, + _db.EngineName, + WorkloadProfiles.TotalRows(_profile), + sw.Elapsed, + rowsAffected ?? (rowsRead.HasValue ? null : actualRows), + rowsRead.HasValue ? actualRows : null, + allocatedBytes); + var allocationText = allocatedBytes.HasValue + ? $"{allocatedBytes.Value:N0} bytes" + : "n/a"; + Console.WriteLine($" {name}: {result.Duration.TotalSeconds:F3}s rows={actualRows:N0} ops/s={result.OperationsPerSecond:N0} alloc={allocationText}"); + return result; + } + + private void WriteExplainPlans() + { + if (_explainDirectory is null) + { + return; + } + + Directory.CreateDirectory(_explainDirectory); + foreach (var (fileName, sql) in ExplainQueries()) + { + var path = Path.Combine(_explainDirectory, fileName); + var text = new StringBuilder(); + text.AppendLine("SQL:"); + text.AppendLine(sql.Trim()); + text.AppendLine(); + text.AppendLine("PLAN:"); + + try + { + text.AppendLine(_db.ExplainPlan(sql).TrimEnd()); + } + catch (Exception ex) + { + text.AppendLine($"EXPLAIN failed: {ex.GetType().Name}: {ex.Message}"); + } + + File.WriteAllText(path, text.ToString()); + } + } + + private static IEnumerable<(string FileName, string Sql)> ExplainQueries() + { + yield return ("06-point-read-pk.txt", "SELECT id, email, full_name FROM users WHERE id = 1;"); + yield return ("07a-raw-joined-aggregate.txt", """ + SELECT c.name, COUNT(DISTINCT u.id) AS user_count, COALESCE(SUM(i.total), 0) AS revenue + FROM companies c + LEFT JOIN users u ON u.company_id = c.id + LEFT JOIN invoices i ON i.user_id = u.id + GROUP BY c.id, c.name + ORDER BY revenue DESC; + """); + yield return ("07b-build-revenue-summary.txt", """ + DELETE FROM company_revenue; + INSERT INTO company_revenue (company_id, user_count, revenue) + SELECT c.id, COUNT(DISTINCT u.id), COALESCE(SUM(i.total), 0) + FROM companies c + JOIN users u ON u.company_id = c.id + LEFT JOIN invoices i ON i.user_id = u.id + GROUP BY c.id; + """); + yield return ("07c-read-revenue-summary.txt", """ + SELECT c.name, cr.user_count, cr.revenue + FROM companies c + JOIN company_revenue cr ON cr.company_id = c.id + ORDER BY cr.revenue DESC; + """); + yield return ("08-substring-search.txt", "SELECT id, full_name FROM users WHERE full_name LIKE '%001%';"); + yield return ("10-window-query.txt", """ + SELECT user_id, invoice_number, total, + ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY total DESC) AS rn, + RANK() OVER (PARTITION BY user_id ORDER BY total DESC) AS rnk + FROM invoices; + """); + yield return ("11-view-unpaid-invoices.txt", "SELECT * FROM v_unpaid_invoices ORDER BY due_at DESC LIMIT 1000;"); + } + + private async Task InitializeSchemaAsync() + { + var ddl = _engine == DatabaseEngine.DecentDB ? Schema.DecentDdl : Schema.SqliteDdl; + foreach (var statement in ddl.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (string.IsNullOrWhiteSpace(statement)) continue; + await _db.ExecuteNonQueryAsync(statement + ";"); + } + } + +} + +public static class Reporter +{ + public static void PrintComparison(IReadOnlyList decent, IReadOnlyList sqlite) + { + Console.WriteLine("\n" + new string('=', 120)); + Console.WriteLine("BENCHMARK COMPARISON: DecentDB vs SQLite (lower duration is better)"); + Console.WriteLine(new string('=', 120)); + Console.WriteLine(string.Format("{0,-40} {1,14} {2,14} {3,12} {4,10} {5,14} {6,14}", "Scenario", "DecentDB (s)", "SQLite (s)", "Ratio S/D", "Winner", "D rows/s", "S rows/s")); + Console.WriteLine(new string('-', 120)); + + for (int i = 0; i < decent.Count; i++) + { + var d = decent[i]; + var s = sqlite.FirstOrDefault(r => r.Scenario == d.Scenario); + if (s == null) continue; + var ratio = s.Duration.TotalSeconds / d.Duration.TotalSeconds; + var winner = d.Duration < s.Duration ? "DecentDB" : (s.Duration < d.Duration ? "SQLite" : "Tie"); + Console.WriteLine($"{d.Scenario,-40} {d.Duration.TotalSeconds,14:F3} {s.Duration.TotalSeconds,14:F3} {ratio,12:F2} {winner,10} {d.OperationsPerSecond,14:N0} {s.OperationsPerSecond,14:N0}"); + } + + Console.WriteLine(new string('-', 120)); + var decentTotal = decent.Sum(r => r.Duration.TotalSeconds); + var sqliteTotal = sqlite.Sum(r => r.Duration.TotalSeconds); + Console.WriteLine(string.Format("{0,-40} {1,14:F3} {2,14:F3} {3,12:F2} {4,10}", "TOTAL", decentTotal, sqliteTotal, sqliteTotal / decentTotal, decentTotal < sqliteTotal ? "DecentDB" : "SQLite")); + Console.WriteLine(new string('=', 120)); + Console.WriteLine(); + Console.WriteLine("NOTES:"); + Console.WriteLine("- DecentDB tuned with embedded_fast profile, 128MB cache, async_commit:10, wal_autocheckpoint=0, hot row sources, 2MB plan cache."); + Console.WriteLine("- SQLite tuned with WAL mode, synchronous=NORMAL, 64MB cache, foreign_keys=ON, temp_store=MEMORY."); + Console.WriteLine("- Hot-loop DML and point/search reads reuse prepared ADO.NET commands and parameters for both engines."); + Console.WriteLine("- Tiny/noisy company insert, point-read, and summary-read scenarios are scaled to stable operation counts."); + Console.WriteLine("- Both engines use equivalent tuned CRM schemas with FKs, workload indexes, partial/covering indexes."); + Console.WriteLine("- Aggregate workload now measures raw aggregation, summary maintenance, and summary-read separately."); + Console.WriteLine("- DecentDB supports native UUID, RETURNING, TRUNCATE, DISTINCT ON, trigram indexes, and richer analytics."); + Console.WriteLine("- SQLite has broader ecosystem, smaller footprint, and broader legacy SQL surface."); + } + + public static void PrintSummary(IReadOnlyList summary) + { + if (summary.Count == 0) + { + return; + } + + Console.WriteLine("\n" + new string('=', 148)); + Console.WriteLine("MEASURED ITERATION SUMMARY"); + Console.WriteLine(new string('=', 148)); + Console.WriteLine(string.Format("{0,-40} {1,-9} {2,5} {3,12} {4,12} {5,12} {6,12} {7,16}", "Scenario", "Engine", "N", "mean ms", "median ms", "p95 ms", "stddev ms", "mean alloc bytes")); + Console.WriteLine(new string('-', 148)); + foreach (var item in summary) + { + var allocationText = item.MeanAllocatedBytes.HasValue + ? item.MeanAllocatedBytes.Value.ToString("N0") + : "n/a"; + Console.WriteLine($"{item.Scenario,-40} {item.Engine,-9} {item.Iterations,5:N0} {item.MeanMs,12:F3} {item.MedianMs,12:F3} {item.P95Ms,12:F3} {item.StdDevMs,12:F3} {allocationText,16}"); + } + + Console.WriteLine(new string('=', 148)); + } + + public static void PrintFeatureMatrix() + { + Console.WriteLine(); + Console.WriteLine("FEATURE HIGHLIGHTS:"); + Console.WriteLine(new string('-', 80)); + Console.WriteLine(string.Format("{0,-40} {1,-15} {2,-15}", "Feature", "DecentDB", "SQLite")); + Console.WriteLine(new string('-', 80)); + PrintRow("Foreign Keys", "Yes", "Yes (must enable)"); + PrintRow("Indexes (B-tree)", "Yes", "Yes"); + PrintRow("Trigram substring indexes", "Yes", "No (FTS5 separate)"); + PrintRow("Views", "Yes", "Yes"); + PrintRow("RETURNING clause", "Yes", "Yes (3.35+)"); + PrintRow("TRUNCATE TABLE", "Yes", "No"); + PrintRow("DISTINCT ON", "Yes", "No"); + PrintRow("Native UUID type", "Yes", "No"); + PrintRow("Statistical aggregates", "Built-in", "Extension only"); + PrintRow("Default durability", "Fsync-on-commit", "PRAGMA-tuned"); + PrintRow("ATTACH DATABASE", "No", "Yes"); + PrintRow("Cross-process sharing", "Native coordination", "File locking"); + Console.WriteLine(new string('-', 80)); + + static void PrintRow(string feature, string decent, string sqlite) + { + Console.WriteLine($"{feature,-40} {decent,-15} {sqlite,-15}"); + } + } +} + +public class Program +{ + public static async Task Main(string[] args) + { + var options = RunOptions.Parse(args); + var size = options.Size; + var startedAt = DateTimeOffset.UtcNow; + + var profile = WorkloadProfiles.Get(size); + var data = WorkloadDataFactory.Create(profile, options.DataSeed); + var runId = $"{size.ToString().ToLowerInvariant()}-{DateTime.UtcNow:yyyyMMddHHmmss}"; + Console.WriteLine($"Scenario: {size}"); + Console.WriteLine($"Approximate total rows: {WorkloadProfiles.TotalRows(profile):N0}"); + Console.WriteLine($"Warmup iterations: {options.WarmupIterations}"); + Console.WriteLine($"Measured iterations: {options.Iterations}"); + Console.WriteLine($"Data seed: {options.DataSeed}"); + Console.WriteLine($"Durability profile: {options.DurabilityProfile}"); + Console.WriteLine($"DecentDB native hot paths: {(options.UseNativeDecentDbHotPaths ? "enabled" : "disabled (default)")}"); + Console.WriteLine($"Allocation telemetry: {(options.CollectAllocations ? "enabled" : "disabled (default)")}"); + Console.WriteLine($"Engine selection: {options.EngineSelection}"); + + var outputRoot = options.OutputDirectory is null + ? Path.Combine(Path.GetTempPath(), $"decentdb-crm-bench-{runId}") + : Path.GetFullPath(options.OutputDirectory); + var root = Path.Combine(outputRoot, runId); + Directory.CreateDirectory(root); + + var jsonResults = new List(); + List? lastDecentResults = null; + List? lastSqliteResults = null; + bool lastDecentNativeHotPathsActive = false; + IReadOnlyList measuredEngineOrder = GetEngineOrderForIteration(1, options.AlternateOrder, options.EngineSelection) + .Select(engine => engine.ToString()) + .ToArray(); + + for (var warmup = 1; warmup <= options.WarmupIterations; warmup++) + { + Console.WriteLine($"\n=== Warmup {warmup:N0}/{options.WarmupIterations:N0} (discarded) ==="); + var warmupRoot = Path.Combine(root, $"warmup-{warmup:D3}"); + Directory.CreateDirectory(warmupRoot); + await RunEnginePairAsync("warmup", warmup, warmupRoot, runId, profile, data, options.AlternateOrder, options.DurabilityProfile, options.EngineSelection, recordResults: false, jsonResults, options.UseNativeDecentDbHotPaths, options.CollectAllocations); + } + + for (var iteration = 1; iteration <= options.Iterations; iteration++) + { + Console.WriteLine($"\n=== Iteration {iteration:N0}/{options.Iterations:N0} ==="); + var iterationRoot = Path.Combine(root, $"iteration-{iteration:D3}"); + Directory.CreateDirectory(iterationRoot); + + (lastDecentResults, lastSqliteResults, lastDecentNativeHotPathsActive, measuredEngineOrder) = await RunEnginePairAsync( + "measurement", + iteration, + iterationRoot, + runId, + profile, + data, + options.AlternateOrder, + options.DurabilityProfile, + options.EngineSelection, + recordResults: true, + jsonResults, + options.UseNativeDecentDbHotPaths, + options.CollectAllocations); + } + + if (lastDecentResults is not null && lastSqliteResults is not null) + { + Reporter.PrintComparison(lastDecentResults, lastSqliteResults); + } + + var summary = BuildSummary(jsonResults); + Reporter.PrintSummary(summary); + Reporter.PrintFeatureMatrix(); + + if (options.JsonPath is not null) + { + var manifest = new BenchmarkManifest( + "dotnet-crm", + runId, + size.ToString(), + WorkloadProfiles.TotalRows(profile), + options.Iterations, + options.WarmupIterations, + options.AlternateOrder, + options.DataSeed, + startedAt, + DateTimeOffset.UtcNow, + root, + Environment.MachineName, + Environment.ProcessorCount, + Environment.Version.ToString(), + RuntimeInformation.OSDescription, + RuntimeInformation.OSArchitecture.ToString(), + RuntimeInformation.ProcessArchitecture.ToString(), + typeof(DecentDBConnection).Assembly.GetName().Version?.ToString() ?? "unknown", + DecentDBConnection.EngineVersion(), + DecentDBConnection.AbiVersion(), + options.DurabilityProfile.ToString(), + options.UseNativeDecentDbHotPaths, + lastDecentNativeHotPathsActive, + options.CollectAllocations, + typeof(SqliteConnection).Assembly.GetName().Version?.ToString() ?? "unknown", + GetSQLiteNativeVersion(), + measuredEngineOrder, + GetDotnetSdkVersion(), + GetAssemblyVersion(typeof(DecentDBConnection)), + "DecentDB.AdoNet/" + GetAssemblyVersion(typeof(DecentDBConnection))); + + var payload = new BenchmarkJsonOutput(manifest, jsonResults, summary); + var jsonPath = Path.GetFullPath(options.JsonPath); + var directory = Path.GetDirectoryName(jsonPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + var json = JsonSerializer.Serialize( + payload, + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }); + await File.WriteAllTextAsync(jsonPath, json); + Console.WriteLine($"\nJSON results written to: {jsonPath}"); + } + + Console.WriteLine($"\nBenchmark files written to: {root}"); + } + + private static async Task<(List? DecentDB, List? SQLite, bool DecentDBNativeHotPathsActive, IReadOnlyList EngineOrder)> RunEnginePairAsync( + string phase, + int iteration, + string iterationRoot, + string runId, + WorkloadProfile profile, + WorkloadData data, + bool alternateOrder, + DurabilityProfile durabilityProfile, + EngineSelection engineSelection, + bool recordResults, + List jsonResults, + bool useNativeDecentDbHotPaths, + bool collectAllocations) + { + List? decentResults = null; + List? sqliteResults = null; + bool decentNativeHotPathsActive = false; + var order = GetEngineOrderForIteration(iteration, alternateOrder, engineSelection).ToList(); + + for (var orderIndex = 0; orderIndex < order.Count; orderIndex++) + { + var (engineResults, nativeRunnerActive) = await RunEngineAsync( + order[orderIndex], + profile, + data, + phase, + iteration, + orderIndex + 1, + iterationRoot, + runId, + durabilityProfile, + recordResults, + jsonResults, + useNativeDecentDbHotPaths, + collectAllocations); + + if (order[orderIndex] == DatabaseEngine.DecentDB) + { + decentResults = engineResults; + decentNativeHotPathsActive = nativeRunnerActive; + } + else + { + sqliteResults = engineResults; + } + } + + return (decentResults, sqliteResults, decentNativeHotPathsActive, order.Select(x => x.ToString()).ToArray()); + } + + private static async Task<(List Results, bool NativeHotPathsActive)> RunEngineAsync( + DatabaseEngine engine, + WorkloadProfile profile, + WorkloadData data, + string phase, + int iteration, + int engineOrder, + string iterationRoot, + string runId, + DurabilityProfile durabilityProfile, + bool recordResults, + List jsonResults, + bool useNativeDecentDbHotPaths, + bool collectAllocations) + { + var engineName = engine == DatabaseEngine.DecentDB ? "decentdb" : "sqlite"; + var engineRoot = Path.Combine(iterationRoot, $"{engineOrder:D2}-{engineName}"); + Directory.CreateDirectory(engineRoot); + var path = engine == DatabaseEngine.DecentDB + ? Path.Combine(engineRoot, "decentdb.ddb") + : Path.Combine(engineRoot, "sqlite.db"); + var explainDirectory = Path.Combine(engineRoot, "explain"); + + await using IDatabaseProvider db = engine == DatabaseEngine.DecentDB + ? new DecentDbProvider(path, durabilityProfile) + : new SqliteProvider(path, durabilityProfile == DurabilityProfile.Durable); + + await db.OpenAsync(); + var harness = new BenchmarkHarness(db, engine, profile, data, explainDirectory, durabilityProfile, useNativeDecentDbHotPaths, collectAllocations); + await harness.RunAsync(); + var results = harness.Results.ToList(); + var dbBytes = DatabaseFileBytes(path); + + if (recordResults) + { + foreach (var result in results) + { + jsonResults.Add(new BenchmarkScenarioResult( + runId, + phase, + iteration, + result.Engine, + engineOrder, + result.Scenario, + result.TotalRows, + result.Duration.TotalMilliseconds, + result.RowsAffected, + result.RowsRead, + result.OperationsPerSecond, + path, + dbBytes, + result.AllocatedBytes)); + } + } + + return (results, harness.NativeDecentDbHotPathsActive); + } + + private static IReadOnlyList BuildSummary(IReadOnlyList results) + { + return results + .GroupBy(r => new { r.Scenario, r.Engine }) + .OrderBy(g => g.Key.Scenario, StringComparer.Ordinal) + .ThenBy(g => g.Key.Engine, StringComparer.Ordinal) + .Select(g => + { + var durations = g.Select(r => r.DurationMs).Order().ToArray(); + var ops = g.Select(r => r.OperationsPerSecond).ToArray(); + var allocated = g + .Where(r => r.AllocatedBytes.HasValue) + .Select(r => (double)r.AllocatedBytes!.Value) + .ToArray(); + var meanAllocatedBytes = allocated.Length == 0 + ? (double?)null + : allocated.Average(); + return new BenchmarkSummary( + g.Key.Scenario, + g.Key.Engine, + durations.Length, + durations.Average(), + Percentile(durations, 50), + Percentile(durations, 95), + durations[0], + durations[^1], + StdDev(durations), + ops.Average(), + meanAllocatedBytes); + }) + .ToArray(); + } + + private static double Percentile(double[] sortedValues, double percentile) + { + if (sortedValues.Length == 0) + { + return 0; + } + + if (sortedValues.Length == 1) + { + return sortedValues[0]; + } + + var rank = (percentile / 100.0) * (sortedValues.Length - 1); + var lower = (int)Math.Floor(rank); + var upper = (int)Math.Ceiling(rank); + if (lower == upper) + { + return sortedValues[lower]; + } + + var weight = rank - lower; + return sortedValues[lower] + (sortedValues[upper] - sortedValues[lower]) * weight; + } + + private static double StdDev(double[] values) + { + if (values.Length <= 1) + { + return 0; + } + + var mean = values.Average(); + var variance = values.Sum(value => Math.Pow(value - mean, 2)) / (values.Length - 1); + return Math.Sqrt(variance); + } + + private static IReadOnlyList GetEngineOrderForIteration( + int iteration, + bool alternateOrder, + EngineSelection engineSelection) + { + var runSQLiteFirst = engineSelection == EngineSelection.All + && alternateOrder + && iteration % 2 == 0; + + var order = new List(2); + if (engineSelection == EngineSelection.SQLite) + { + order.Add(DatabaseEngine.SQLite); + return order; + } + + if (engineSelection == EngineSelection.DecentDB) + { + order.Add(DatabaseEngine.DecentDB); + return order; + } + + if (runSQLiteFirst) + { + order.Add(DatabaseEngine.SQLite); + order.Add(DatabaseEngine.DecentDB); + return order; + } + + order.Add(DatabaseEngine.DecentDB); + order.Add(DatabaseEngine.SQLite); + return order; + } + + private static string GetSQLiteNativeVersion() + { + try + { + using var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT sqlite_version();"; + return command.ExecuteScalar()?.ToString() ?? "unknown"; + } + catch + { + return "unknown"; + } + } + + private static string GetDotnetSdkVersion() + { + try + { + using var process = new Process(); + process.StartInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = "--version", + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + process.Start(); + var output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(5000); + + return process.ExitCode == 0 && output.Length > 0 + ? output + : "unknown"; + } + catch + { + return "unknown"; + } + } + + private static string GetAssemblyVersion(Type targetType) + { + try + { + return targetType.Assembly.GetName().Version?.ToString() ?? "unknown"; + } + catch + { + return "unknown"; + } + } + + private static long DatabaseFileBytes(string path) + { + long total = 0; + foreach (var candidate in new[] + { + path, + path + ".wal", + path + "-wal", + path + "-shm", + path + ".coord" + }) + { + if (File.Exists(candidate)) + { + total += new FileInfo(candidate).Length; + } + } + + return total; + } +} diff --git a/bindings/dotnet/benchmarks/DecentDB.CrmComparison/README.md b/bindings/dotnet/benchmarks/DecentDB.CrmComparison/README.md new file mode 100644 index 00000000..7ec98fc4 --- /dev/null +++ b/bindings/dotnet/benchmarks/DecentDB.CrmComparison/README.md @@ -0,0 +1,182 @@ +# DecentDB CRM Comparison Benchmark + +This benchmark compares DecentDB ADO.NET against `Microsoft.Data.Sqlite` on a +CRM-style embedded workload. It is intended as a repeatable local engineering +harness, not a product-wide performance claim. + +## Durability And Schema Scope + +The default mode is a relaxed local comparison: DecentDB uses `async_commit:10` +with `embedded_fast`, and SQLite uses WAL with `synchronous=NORMAL`. These are +not full-durability default settings. + +The schemas are equivalent for the measured workload, but not byte-for-byte +identical. Each engine gets the closest supported form for partial/covering +indexes and text search, and the harness avoids indexes that no measured scenario +uses. + +## Build + +```bash + dotnet build bindings/dotnet/benchmarks/DecentDB.CrmComparison/DecentDB.CrmComparison.csproj -c Release +``` + +## Fast Smoke Run + +```bash +dotnet run --project bindings/dotnet/benchmarks/DecentDB.CrmComparison/DecentDB.CrmComparison.csproj -c Release -- \ + --size Tiny \ + --warmup-iterations 1 \ + --iterations 2 \ + --seed 42 \ + --out-dir .tmp/crm-comparison \ + --engines all \ + --no-decentdb-native-hot-paths \ + --json .tmp/crm-comparison/tiny-results.json +``` + +## Larger Local Run + +```bash +dotnet run --project bindings/dotnet/benchmarks/DecentDB.CrmComparison/DecentDB.CrmComparison.csproj -c Release -- \ + --size Small \ + --warmup-iterations 1 \ + --iterations 5 \ + --seed 42 \ + --out-dir .tmp/crm-comparison \ + --engines all \ + --no-decentdb-native-hot-paths \ + --json .tmp/crm-comparison/small-results.json +``` + +You can also run one mode through the helper: + +```bash +bash bindings/dotnet/benchmarks/DecentDB.CrmComparison/run-crm-benchmark.sh .tmp/crm-comparison Small relaxed 5 1 0 42 all +``` + +Argument order for the helper script is: + +1. output directory +2. size (`Tiny|Small|Medium|Large|Jumbo`) +3. durability (`relaxed|durable`) +4. measured iterations +5. warmup iterations +6. native mode (`1` to enable, `0` to disable) +7. seed +8. engines (`all`, `decentdb`, `sqlite`) +9. collect allocations (`1` to enable, `0` to disable; default: `0`) + +## Matrix Runner + +Run all canonical comparison modes through one wrapper: + +```bash +bash bindings/dotnet/benchmarks/DecentDB.CrmComparison/run-crm-benchmark-matrix.sh --size Small --iterations 3 --warmup 1 --run-id ci-smoke +``` + +Enable allocation telemetry for an entire matrix run with: + +```bash +bash bindings/dotnet/benchmarks/DecentDB.CrmComparison/run-crm-benchmark-matrix.sh --size Small --iterations 3 --collect-allocations +``` + +The matrix runner executes: + +1. DecentDB ADO.NET vs SQLite, relaxed +2. DecentDB ADO.NET vs SQLite, durable +3. SQLite ADO.NET only, relaxed +4. SQLite ADO.NET only, durable +5. Optional DecentDB native-only modes when `--native-on` is set (relaxed and durable) + +Each mode writes `validation.json` beside `results.json`, and the matrix run writes a +top-level `matrix-summary.json` for lightweight trend capture. + +## Options + +- `--size Tiny|Small|Medium|Large|Jumbo`: workload scale. +- `--warmup-iterations `: runs discarded before measured iterations. +- `--iterations `: measured iterations written to JSON and summaries. +- `--seed `: deterministic data and query sample seed. +- `--out-dir `: artifact root. Use `.tmp/` for local runs. +- `--run-id `: stable matrix directory name, useful for CI artifact capture (defaults to timestamp). +- `--json `: machine-readable measured results. +- `--no-alternate-order`: always run DecentDB before SQLite. +- `--durability `: relaxed (default) or durable settings. +- `--decentdb-native-hot-paths`: experimental native `DecentDB.Native` path for + point reads, update, window, and delete workloads. +- `--no-decentdb-native-hot-paths`: force ADO.NET-only execution. This is the + default because native path behavior is still being tuned for full-suite stability. +- `--decentdb-relaxed`: explicit relaxed profile. +- `--decentdb-durable`: explicit durable profile. +- `--engines `: limit engine execution for harness separation. +- `--collect-allocations`: enable managed allocation telemetry by scenario using + `GC.GetAllocatedBytesForCurrentThread()`. +- `--no-collect-allocations`: force allocation telemetry off (default). + +## Logs + +Each benchmark run writes a plain-text log file at: +`//benchmark.log`. +Artifacts now include both JSON and shell-captured command logs, which keeps CI and +manual review scripts parsing the same canonical command contract. + +## Regression Guard + +Use `compare-crm-benchmark.py` to compare measured summaries and validate per-mode outputs: + +```bash +python bindings/dotnet/benchmarks/DecentDB.CrmComparison/compare-crm-benchmark.py \ + --baseline .tmp/crm-comparison/baseline.json \ + --current .tmp/crm-comparison/current.json \ + --max-regression 0.10 \ + --max-allocation-regression 0.10 \ + --check-decentdb-win +``` + +For local smoke checks without a baseline file, validate output completeness with: + +```bash +python bindings/dotnet/benchmarks/DecentDB.CrmComparison/compare-crm-benchmark.py \ + --current .tmp/crm-comparison/tiny-results.json \ + --require-complete \ + --expected-engines DecentDB,SQLite +``` + +Matrix mode runs now write `validation.json` beside each `results.json` with the +mode-level coverage and regression payload. + +The guardrail fails if: + +- a scenario/engine pair is missing from current output; +- any mean duration regresses beyond the configured ratio; or +- any collected mean allocation count regresses beyond the configured ratio; or +- DecentDB fails the lead policy when `--check-decentdb-win` is set. + +## Scenario Split (Phase 10) + +Scenario 07 is split to keep benchmark truthfulness: + +- `07a. Raw Joined Aggregate` — full relational group-by and sum path. +- `07b. Build Revenue Summary` — materialized summary maintenance. +- `07c. Read Revenue Summary` — reads precomputed summary rows. + +The split prevents the aggregate read from accidentally bypassing executor-path +coverage. + +## Artifacts + +Each run creates a timestamped directory under `--out-dir`. Warmup directories +are retained but omitted from JSON results. Measured iteration directories are +named `iteration-NNN`, with engine subdirectories prefixed by run order, such as +`01-decentdb` and `02-sqlite`. + +Each engine directory contains: + +- the database files used for that engine run; +- `explain/*.txt` files for the key SELECT scenarios. + +The JSON manifest records the workload seed, warmup/measured iteration counts, +engine order per measured scenario result, runtime/platform details, DecentDB +engine/ABI details, SQLite provider/native versions, raw scenario timings, +optional allocation telemetry, and grouped summary statistics. diff --git a/bindings/dotnet/benchmarks/DecentDB.CrmComparison/compare-crm-benchmark.py b/bindings/dotnet/benchmarks/DecentDB.CrmComparison/compare-crm-benchmark.py new file mode 100644 index 00000000..e9337a7b --- /dev/null +++ b/bindings/dotnet/benchmarks/DecentDB.CrmComparison/compare-crm-benchmark.py @@ -0,0 +1,410 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Iterable, List, Sequence, Tuple + + +EXPECTED_SCENARIOS = ( + "01. Bulk Insert Companies", + "02. Bulk Insert Users", + "03. Bulk Insert Addresses", + "04. Bulk Insert Invoices", + "05. Bulk Insert Invoice Items", + "06. Point Reads (PK lookup)", + "07a. Raw Joined Aggregate", + "07b. Build Revenue Summary", + "07c. Read Revenue Summary", + "08. Substring Search (LIKE %pattern%)", + "09. Update Invoices Paid", + "10. Complex Window/Analytic Query", + "11. View Query (Unpaid Invoices)", + "12. Delete Cascade Test", +) + + +@dataclass(frozen=True) +class SummaryRow: + mean_ms: float + mean_allocated_bytes: float | None + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Compare two DecentDB CRM benchmark JSON outputs" + ) + parser.add_argument( + "--baseline", + default="", + help="Optional baseline CRM benchmark JSON", + ) + parser.add_argument( + "--current", + required=True, + help="Current CRM benchmark JSON", + ) + parser.add_argument( + "--max-regression", + type=float, + default=0.10, + help="Max allowed regression ratio on MeanMs (default: 0.10 = 10%%)", + ) + parser.add_argument( + "--max-allocation-regression", + type=float, + default=None, + help=( + "Optional max allowed regression ratio on MeanAllocatedBytes " + "(for example: 0.10 = 10%%). Disabled when omitted." + ), + ) + parser.add_argument( + "--max-mean-ms", + type=float, + default=None, + help="Optional hard upper bound for scenario mean in current results", + ) + parser.add_argument( + "--expected-scenarios", + default=",".join(EXPECTED_SCENARIOS), + help="Comma-separated expected scenarios (default: canonical CRM suite)", + ) + parser.add_argument( + "--expected-engines", + default="DecentDB,SQLite", + help="Comma-separated expected engines for scenario completeness checks", + ) + parser.add_argument( + "--require-complete", + action="store_true", + help="Require expected scenario/engine pairs in baseline/current rows", + ) + parser.add_argument( + "--check-decentdb-win", + action="store_true", + help="Fail if any scenario has DecentDB slower than SQLite by margin", + ) + parser.add_argument( + "--output", + default=None, + help="Optional JSON report path", + ) + return parser.parse_args() + + +def parse_csv(value: str) -> Tuple[str, ...]: + items = tuple(item.strip() for item in value.split(",") if item.strip()) + if not items: + raise argparse.ArgumentTypeError("expected non-empty comma-separated value") + return items + + +def load_summaries(path: Path) -> Dict[Tuple[str, str], SummaryRow]: + payload = json.loads(path.read_text()) + rows = ( + payload.get("Summary") + or payload.get("Summaries") + or payload.get("summary") + or payload.get("summaries") + or [] + ) + if not isinstance(rows, list): + raise ValueError(f"{path}: summary field is not a list") + + output: Dict[Tuple[str, str], SummaryRow] = {} + for row in rows: + if not isinstance(row, dict): + raise ValueError(f"{path}: summary row is not an object") + scenario = row.get("Scenario") + engine = row.get("Engine") + mean_ms = row.get("MeanMs") + mean_allocated_bytes = row.get("MeanAllocatedBytes") + if scenario is None or engine is None or mean_ms is None: + raise ValueError( + f"{path}: summary row is missing Scenario/Engine/MeanMs" + ) + key = (str(scenario), str(engine)) + if key in output: + raise ValueError(f"{path}: duplicate summary row for {scenario} / {engine}") + mean = float(mean_ms) + if mean <= 0: + raise ValueError(f"{path}: {scenario} / {engine} has non-positive mean duration") + allocated = None + if mean_allocated_bytes is not None: + allocated = float(mean_allocated_bytes) + if allocated < 0: + raise ValueError( + f"{path}: {scenario} / {engine} has negative mean allocated bytes" + ) + output[key] = SummaryRow(mean_ms=mean, mean_allocated_bytes=allocated) + return output + + +def expected_pairs( + scenarios: Sequence[str], + engines: Sequence[str], +) -> Iterable[Tuple[str, str]]: + for scenario in scenarios: + for engine in engines: + yield (scenario, engine) + + +def build_regression_report( + baseline: str | None, + current: str, + max_regression: float, + max_allocation_regression: float | None, + require_complete: bool, + expected_scenarios: Sequence[str], + expected_engines: Sequence[str], + failures: Sequence[Tuple[Tuple[str, str], float, float, float]], + allocation_failures: Sequence[Tuple[Tuple[str, str], float, float, float | None]], + missing_baseline: Sequence[Tuple[str, str]], + missing_current: Sequence[Tuple[str, str]], + max_mean_failures: Sequence[Tuple[Tuple[str, str], float, float]], + decentdb_not_leading: Sequence[Tuple[str, float, float, float]], +) -> str: + return json.dumps( + { + "baseline": baseline, + "current": current, + "max_regression": max_regression, + "max_allocation_regression": max_allocation_regression, + "require_complete": require_complete, + "required_scenarios": list(expected_scenarios), + "required_engines": list(expected_engines), + "regressions": [ + { + "scenario": k[0], + "engine": k[1], + "baseline_mean_ms": baseline_mean, + "current_mean_ms": current_mean, + "ratio": ratio, + } + for k, baseline_mean, current_mean, ratio in failures + ], + "allocation_regressions": [ + { + "scenario": k[0], + "engine": k[1], + "baseline_mean_allocated_bytes": baseline_allocated, + "current_mean_allocated_bytes": current_allocated, + "ratio": ratio, + } + for k, baseline_allocated, current_allocated, ratio in allocation_failures + ], + "missing_in_baseline": [ + {"scenario": scenario, "engine": engine} + for scenario, engine in sorted(missing_baseline) + ], + "missing_in_current": [ + {"scenario": scenario, "engine": engine} + for scenario, engine in sorted(missing_current) + ], + "max_mean_regressions": [ + { + "scenario": k[0], + "engine": k[1], + "current_mean_ms": current_mean, + "max_mean_ms": max_mean_ms, + } + for k, current_mean, max_mean_ms in max_mean_failures + ], + "decentdb_not_leading": [ + { + "scenario": scenario, + "decent_ms": decent_mean, + "sqlite_ms": sqlite_mean, + "sqlite_over_decent": ratio, + } + for scenario, decent_mean, sqlite_mean, ratio in decentdb_not_leading + ], + }, + indent=2, + ) + + +def main() -> int: + args = parse_args() + current = load_summaries(Path(args.current)) + baseline = load_summaries(Path(args.baseline)) if args.baseline else None + + expected_scenarios = parse_csv(args.expected_scenarios) + expected_engines = parse_csv(args.expected_engines) + + threshold = 1.0 + args.max_regression + failures: List[Tuple[Tuple[str, str], float, float, float]] = [] + allocation_failures: List[Tuple[Tuple[str, str], float, float, float | None]] = [] + max_mean_failures: List[Tuple[Tuple[str, str], float, float]] = [] + missing_baseline: List[Tuple[str, str]] = [] + missing_current: List[Tuple[str, str]] = [] + + if args.require_complete and baseline is not None: + for pair in expected_pairs(expected_scenarios, expected_engines): + if pair not in baseline: + missing_baseline.append(pair) + if args.require_complete: + for pair in expected_pairs(expected_scenarios, expected_engines): + if pair not in current: + missing_current.append(pair) + + if args.max_mean_ms is not None: + for pair, summary in current.items(): + if summary.mean_ms > args.max_mean_ms: + max_mean_failures.append((pair, summary.mean_ms, args.max_mean_ms)) + + if baseline is not None: + for pair, baseline_summary in baseline.items(): + if args.require_complete and pair in missing_baseline: + continue + if pair not in current: + if pair not in missing_current: + missing_current.append(pair) + continue + + baseline_mean = baseline_summary.mean_ms + current_mean = current[pair].mean_ms + if baseline_mean <= 0: + continue + ratio = current_mean / baseline_mean + if ratio > threshold: + failures.append((pair, baseline_mean, current_mean, ratio)) + + if baseline is not None and args.max_allocation_regression is not None: + allocation_threshold = 1.0 + args.max_allocation_regression + for pair, baseline_summary in baseline.items(): + if args.require_complete and pair in missing_baseline: + continue + if pair not in current: + continue + + baseline_allocated = baseline_summary.mean_allocated_bytes + current_allocated = current[pair].mean_allocated_bytes + if baseline_allocated is None or current_allocated is None: + continue + if baseline_allocated == 0: + if current_allocated > 0: + allocation_failures.append( + (pair, baseline_allocated, current_allocated, None) + ) + continue + + ratio = current_allocated / baseline_allocated + if ratio > allocation_threshold: + allocation_failures.append( + (pair, baseline_allocated, current_allocated, ratio) + ) + + decentdb_not_leading: List[Tuple[str, float, float, float]] = [] + if args.check_decentdb_win: + for (scenario, engine), summary in current.items(): + if engine != "DecentDB": + continue + sqlite_key = (scenario, "SQLite") + if sqlite_key not in current: + continue + decent_mean = summary.mean_ms + sqlite_mean = current[sqlite_key].mean_ms + if sqlite_mean <= 0 or decent_mean <= 0: + continue + ratio = sqlite_mean / decent_mean + if ratio < 1.10: + decentdb_not_leading.append((scenario, decent_mean, sqlite_mean, ratio)) + + if baseline is None: + if args.require_complete: + print("Current summary coverage:") + if missing_current: + print("Missing scenario/engine pairs in current:") + for scenario, engine in sorted(missing_current): + print(f" - {scenario} / {engine}") + else: + print(f" - all {len(expected_scenarios)} scenarios present for {', '.join(expected_engines)}") + else: + print("Current summary present:") + print(f" - {len(current)} scenario/engine rows") + else: + if missing_baseline: + print("Missing scenarios in baseline:") + for scenario, engine in sorted(missing_baseline): + print(f" - {scenario} / {engine}") + if missing_current: + print("Missing scenarios in current:") + for scenario, engine in sorted(missing_current): + print(f" - {scenario} / {engine}") + + if failures: + print("Regression detected:") + for (scenario, engine), baseline_mean, current_mean, ratio in sorted(failures): + pct = (ratio - 1.0) * 100 + print( + f" - {scenario} / {engine}: baseline {baseline_mean:.3f}ms -> " + f"current {current_mean:.3f}ms (+{pct:.1f}%)" + ) + + if allocation_failures: + print("Allocation regression detected:") + for (scenario, engine), baseline_allocated, current_allocated, ratio in sorted( + allocation_failures + ): + if ratio is None: + print( + f" - {scenario} / {engine}: baseline {baseline_allocated:.0f}B -> " + f"current {current_allocated:.0f}B" + ) + else: + pct = (ratio - 1.0) * 100 + print( + f" - {scenario} / {engine}: baseline {baseline_allocated:.0f}B -> " + f"current {current_allocated:.0f}B (+{pct:.1f}%)" + ) + + if max_mean_failures: + print("Max-mean limits exceeded:") + for (scenario, engine), current_mean, max_mean_ms in sorted(max_mean_failures): + print(f" - {scenario} / {engine}: {current_mean:.3f}ms > {max_mean_ms:.3f}ms") + + if decentdb_not_leading: + print("DecentDB/SQLite lead target missed:") + for scenario, decent_mean, sqlite_mean, ratio in sorted(decentdb_not_leading): + print( + f" - {scenario}: DecentDB={decent_mean:.3f}ms, SQLite={sqlite_mean:.3f}ms, " + f"SQLite/DecentDB={ratio:.2f}x" + ) + + if args.output: + Path(args.output).write_text( + build_regression_report( + args.baseline or None, + args.current, + args.max_regression, + args.max_allocation_regression, + args.require_complete, + expected_scenarios, + expected_engines, + failures, + allocation_failures, + missing_baseline, + missing_current, + max_mean_failures, + decentdb_not_leading, + ) + ) + + failed = bool( + failures + or allocation_failures + or missing_current + or missing_baseline + or max_mean_failures + ) + if args.check_decentdb_win: + failed = failed or bool(decentdb_not_leading) + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bindings/dotnet/benchmarks/DecentDB.CrmComparison/run-crm-benchmark-matrix.sh b/bindings/dotnet/benchmarks/DecentDB.CrmComparison/run-crm-benchmark-matrix.sh new file mode 100755 index 00000000..5bd3f49a --- /dev/null +++ b/bindings/dotnet/benchmarks/DecentDB.CrmComparison/run-crm-benchmark-matrix.sh @@ -0,0 +1,234 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +PROJECT_DIR="$SCRIPT_DIR" +RUNNER="$PROJECT_DIR/run-crm-benchmark.sh" + +usage() { + cat <<'USAGE' +Usage: + run-crm-benchmark-matrix.sh [options] + +Options: + --out-dir PATH Output root directory (default: .tmp/crm-benchmark-matrix) + --size SIZE Tiny|Small|Medium|Large|Jumbo (default: Small) + --iterations N Measurement iterations per mode (default: 3) + --warmup N Warmup iterations per mode (default: 1) + --seed N Dataset seed (default: 42) + --run-id ID Stable matrix output suffix (default: date/timestamp) + --collect-allocations Enable managed allocation telemetry for all matrix modes (default: off) + --no-collect-allocations Explicitly disable managed allocation telemetry (default) + --native-on Enable native DecentDB modes (decentdb-only harness) + --skip-native Skip native entries regardless of native availability + --help Show this help text + +This runs six modes by default: + - DecentDB ADO.NET vs SQLite (relaxed) + - DecentDB ADO.NET vs SQLite (durable) + - SQLite ADO.NET only (relaxed) + - SQLite ADO.NET only (durable) + - DecentDB native ADO path only (relaxed) [optional] + - DecentDB native ADO path only (durable) [optional] + +Each mode writes its own JSON, logs, and database artifacts into a mode-specific +subdirectory. Each mode now also emits `validation.json` for summary completeness and +regression checks used by CI. +USAGE +} + +OUT_DIR=".tmp/crm-benchmark-matrix" +SIZE="Small" +ITERATIONS=3 +WARMUP=1 +SEED=42 +NATIVE_ON=0 +SKIP_NATIVE=0 +COLLECT_ALLOCATIONS=0 +RUN_ID="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --out-dir) + OUT_DIR=$2 + shift 2 + ;; + --size) + SIZE=$2 + shift 2 + ;; + --iterations) + ITERATIONS=$2 + shift 2 + ;; + --warmup) + WARMUP=$2 + shift 2 + ;; + --seed) + SEED=$2 + shift 2 + ;; + --run-id) + RUN_ID=$2 + shift 2 + ;; + --collect-allocations) + COLLECT_ALLOCATIONS=1 + shift + ;; + --no-collect-allocations) + COLLECT_ALLOCATIONS=0 + shift + ;; + --native-on) + NATIVE_ON=1 + shift + ;; + --skip-native) + SKIP_NATIVE=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage + exit 1 + ;; + esac +done + +if [[ "$NATIVE_ON" -eq 1 && "$SKIP_NATIVE" -eq 1 ]]; then + echo "Conflicting options: --native-on and --skip-native" >&2 + exit 1 +fi + +mkdir -p "$OUT_DIR" +STAMP="$(date +%Y%m%d-%H%M%S)" +if [[ -n "$RUN_ID" ]]; then + if [[ "$RUN_ID" == *"/"* ]]; then + echo "Invalid --run-id '$RUN_ID': slash is not allowed." >&2 + exit 1 + fi + if [[ "$RUN_ID" == *[[:space:]]* ]]; then + echo "Invalid --run-id '$RUN_ID': whitespace is not allowed." >&2 + exit 1 + fi + MATRIX_DIR="$OUT_DIR/$RUN_ID" +else + MATRIX_DIR="$OUT_DIR/$STAMP" +fi +mkdir -p "$MATRIX_DIR" + +run_mode() { + local mode="$1" + local durable="$2" + local native="$3" + local engines="$4" + local expected_engines="$5" + local mode_dir="$MATRIX_DIR/$mode" + + mkdir -p "$mode_dir" + printf '\n=== Running mode: %s ===\n' "$mode" + + CRM_BENCHMARK_FIXED_RUN_DIR=1 bash "$RUNNER" \ + "$mode_dir" \ + "$SIZE" \ + "$durable" \ + "$ITERATIONS" \ + "$WARMUP" \ + "$([ "$native" == "1" ] && echo 1 || echo 0)" \ + "$SEED" \ + "$engines" \ + "$COLLECT_ALLOCATIONS" + + # Persist an easy mode marker for downstream scripts. + echo "{\"mode\":\"$mode\",\"size\":\"$SIZE\",\"durability\":\"$durable\",\"seed\":$SEED,\"iterations\":$ITERATIONS,\"warmup\":$WARMUP,\"native\":${native},\"engines\":\"$engines\",\"collect_allocations\":${COLLECT_ALLOCATIONS}}" \ + > "$mode_dir/mode.json" + + if [[ ! -f "$mode_dir/results.json" ]]; then + echo "Expected results file was not written for $mode at $mode_dir/results.json" >&2 + exit 1 + fi + + local validate_log + validate_log="$mode_dir/validation.json" + python "$SCRIPT_DIR/compare-crm-benchmark.py" \ + --current "$mode_dir/results.json" \ + --require-complete \ + --expected-engines "$expected_engines" \ + --output "$validate_log" +} + +run_mode "ado-relaxed" "relaxed" 0 all "DecentDB,SQLite" +run_mode "ado-durable" "durable" 0 all "DecentDB,SQLite" +run_mode "sqlite-relaxed" "relaxed" 0 sqlite "SQLite" +run_mode "sqlite-durable" "durable" 0 sqlite "SQLite" + +if [[ "$SKIP_NATIVE" -eq 0 ]]; then + if [[ "$NATIVE_ON" -eq 0 ]]; then + printf '\nNative modes disabled by default because full-suite native mode can be unstable on this machine.\n' + printf 'Rerun with --native-on to attempt native modes.\n' + else + run_mode "native-relaxed" "relaxed" 1 decentdb "DecentDB" + run_mode "native-durable" "durable" 1 decentdb "DecentDB" + fi +fi + +python - "$MATRIX_DIR" <<'PY' +import json +from pathlib import Path +import sys + + +matrix_dir = Path(sys.argv[1]) +mode_data = {} +for mode_dir in sorted(path for path in matrix_dir.iterdir() if path.is_dir()): + result_path = mode_dir / "results.json" + if not result_path.exists(): + continue + + payload = json.loads(result_path.read_text(encoding="utf-8")) + manifest = payload.get("Manifest") or payload.get("manifest") or {} + rows = payload.get("Summary") or payload.get("summary") or payload.get("summaries") or [] + + scenario_summary = {} + for row in rows: + scenario = row.get("Scenario") + engine = row.get("Engine") + if scenario is None or engine is None: + continue + key = f"{scenario}|{engine}" + scenario_summary[key] = { + "mean_ms": row.get("MeanMs"), + "iterations": row.get("Iterations", 0), + "p95_ms": row.get("P95Ms"), + "stddev_ms": row.get("StdDevMs"), + "mean_alloc_bytes": row.get("MeanAllocatedBytes"), + } + + mode_data[mode_dir.name] = { + "manifest": { + "benchmark": manifest.get("Benchmark"), + "scenario_size": manifest.get("ScenarioSize"), + "durability": manifest.get("DurabilityProfile"), + "iterations": manifest.get("Iterations"), + "warmup_iterations": manifest.get("WarmupIterations"), + "engine_order": manifest.get("engine_order", []), + "decentdb_native_hot_paths_active": manifest.get("NativeDecentDbHotPathsActive"), + "use_native_decentdb_hot_paths": manifest.get("UseNativeDecentDbHotPaths"), + "collect_allocations": manifest.get("CollectAllocations"), + }, + "scenario_count": len(scenario_summary), + "scenarios": scenario_summary, + } + +output_path = matrix_dir / "matrix-summary.json" +output_path.write_text(json.dumps(mode_data, indent=2), encoding="utf-8") +print(f"Wrote matrix summary to {output_path}") +PY + +printf '\nBenchmark matrix written to: %s\n' "$MATRIX_DIR" diff --git a/bindings/dotnet/benchmarks/DecentDB.CrmComparison/run-crm-benchmark.sh b/bindings/dotnet/benchmarks/DecentDB.CrmComparison/run-crm-benchmark.sh new file mode 100755 index 00000000..972943fd --- /dev/null +++ b/bindings/dotnet/benchmarks/DecentDB.CrmComparison/run-crm-benchmark.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +PROJECT_DIR="$SCRIPT_DIR/.." +PROJECT="$PROJECT_DIR/DecentDB.CrmComparison/DecentDB.CrmComparison.csproj" + +OUT_ROOT="${1:-$(pwd)/.tmp/decentdb-crm-comparison}" +SIZE="${2:-Small}" +DURABILITY="${3:-relaxed}" +ITERATIONS="${4:-5}" +WARMUP="${5:-1}" +NATIVE="${6:-0}" +SEED="${7:-42}" +ENGINES="${8:-all}" +COLLECT_ALLOCATIONS="${9:-0}" + +if [[ "$ENGINES" != "all" && "$ENGINES" != "decentdb" && "$ENGINES" != "sqlite" ]]; then + echo "Unknown engines '$ENGINES'. Use all, decentdb, or sqlite." >&2 + exit 1 +fi + +mkdir -p "$OUT_ROOT" +STAMP="$(date +%Y%m%d%H%M%S)" +if [[ "${CRM_BENCHMARK_FIXED_RUN_DIR:-0}" == "1" ]]; then + RUN_DIR="$OUT_ROOT" +else + RUN_DIR="$OUT_ROOT/$STAMP" +fi +mkdir -p "$RUN_DIR" + +LOG_PATH="$RUN_DIR/benchmark.log" +JSON_PATH="$RUN_DIR/results.json" + +DOTNET_OPTIONS=( + "dotnet" + "run" + "-c" + "Release" + "--project" + "$PROJECT" + "--" + "--size" + "$SIZE" + "--iterations" + "$ITERATIONS" + "--warmup-iterations" + "$WARMUP" + "--seed" + "$SEED" + "--out-dir" + "$RUN_DIR" + "--durability" + "$DURABILITY" + "--json" + "$JSON_PATH" + "--engines" + "$ENGINES" +) + +if [[ "$NATIVE" == "1" ]]; then + DOTNET_OPTIONS+=(--decentdb-native-hot-paths) +else + DOTNET_OPTIONS+=(--no-decentdb-native-hot-paths) +fi + +if [[ "$COLLECT_ALLOCATIONS" == "1" ]]; then + DOTNET_OPTIONS+=(--collect-allocations) +elif [[ "$COLLECT_ALLOCATIONS" == "0" ]]; then + DOTNET_OPTIONS+=(--no-collect-allocations) +fi + +printf 'Running benchmark:\n' +printf 'command: %s\n' "${DOTNET_OPTIONS[*]}" +printf 'out-dir: %s\n' "$RUN_DIR" +printf 'json: %s\n' "$JSON_PATH" +printf 'log: %s\n' "$LOG_PATH" +{ + printf 'Benchmark command: %s\n' "${DOTNET_OPTIONS[*]}" + printf 'Started UTC: %s\n' "$(date -u +%FT%TZ)" + printf 'Output directory: %s\n' "$RUN_DIR" + printf 'JSON file: %s\n' "$JSON_PATH" + printf '%s\n' '---' +} > "$LOG_PATH" + +"${DOTNET_OPTIONS[@]}" 2>&1 | tee -a "$LOG_PATH" + +echo "Results:" +echo " out: $RUN_DIR" +echo " json: $JSON_PATH" diff --git a/bindings/dotnet/src/DecentDB.AdoNet/DecentDBCommand.cs b/bindings/dotnet/src/DecentDB.AdoNet/DecentDBCommand.cs index 2191e7f1..a73cedc6 100644 --- a/bindings/dotnet/src/DecentDB.AdoNet/DecentDBCommand.cs +++ b/bindings/dotnet/src/DecentDB.AdoNet/DecentDBCommand.cs @@ -5,6 +5,7 @@ using System.Globalization; using System.Linq; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using System.Diagnostics.CodeAnalysis; @@ -36,6 +37,8 @@ public sealed class DecentDBCommand : DbCommand private DbParameter[]? _cachedRewriteParameterRefs; private string?[]? _cachedRewriteParameterNames; private bool _cachedRewriteNeedsOffsetClamp; + private SingleRowReadPlan? _cachedSingleRowReadPlan; + private SingleInt64NonQueryPlan? _cachedSingleInt64NonQueryPlan; private Int64TextFloat64NonQueryPlan? _cachedInt64TextFloat64NonQueryPlan; private bool _disposed; @@ -338,6 +341,11 @@ protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) if (canUseSingleInt64Step) { + var knownMaxOneRow = TryGetCachedSingleRowReadPlan( + sql, + fastIndex, + out var singleRowPlan) && singleRowPlan.KnownMaxOneRow; + _statement = stmt; _statementCanSkipFinalizeReset = true; @@ -359,6 +367,7 @@ protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) var ex = new DecentDBException(fastStepResult, db.LastErrorMessage, sql); if (attempt == 0 && IsSchemaChangedPreparedStatementError(ex)) { + _cachedSingleRowReadPlan = null; _connection.ClearPreparedStatementCacheForSchemaChange(); continue; } @@ -366,7 +375,7 @@ protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) throw ex; } - return new DecentDBDataReader(this, stmt, fastStepResult, observation); + return new DecentDBDataReader(this, stmt, fastStepResult, observation, knownMaxOneRow); } _statementCanSkipFinalizeReset = false; @@ -395,6 +404,7 @@ protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) var ex = new DecentDBException(stepResult, db.LastErrorMessage, sql); if (attempt == 0 && IsSchemaChangedPreparedStatementError(ex)) { + _cachedSingleRowReadPlan = null; _connection.ClearPreparedStatementCacheForSchemaChange(); continue; } @@ -561,6 +571,8 @@ private void InvalidateRewriteCache() _cachedRewriteParameterRefs = null; _cachedRewriteParameterNames = null; _cachedRewriteNeedsOffsetClamp = false; + _cachedSingleRowReadPlan = null; + _cachedSingleInt64NonQueryPlan = null; _cachedInt64TextFloat64NonQueryPlan = null; } @@ -864,6 +876,612 @@ private static bool TryGetInt64ParameterValue(object? rawValue, out long value) } } + private bool TryGetCachedSingleRowReadPlan( + string sql, + int parameterIndex1Based, + [NotNullWhen(true)] out SingleRowReadPlan? plan) + { + if (_cachedSingleRowReadPlan != null && + _cachedSingleRowReadPlan.Matches(_commandText, sql, parameterIndex1Based, _parameters)) + { + plan = _cachedSingleRowReadPlan; + return true; + } + + plan = null; + if (_connection == null) + { + return false; + } + + var knownMaxOneRow = TryDescribeKnownSingleRowRead(sql, parameterIndex1Based); + plan = new SingleRowReadPlan(_commandText, sql, parameterIndex1Based, _parameters, knownMaxOneRow); + _cachedSingleRowReadPlan = plan; + return true; + } + + private bool TryDescribeKnownSingleRowRead(string sql, int parameterIndex1Based) + { + if (_connection == null) + { + return false; + } + + try + { + if (!TryParseSingleTablePrimaryKeyEquality( + sql, + parameterIndex1Based, + out var sourceTable, + out var sourceColumn)) + { + return false; + } + + return TableHasSingleColumnPrimaryKey(sourceTable, sourceColumn); + } + catch (DecentDBException) + { + return false; + } + catch (JsonException) + { + return false; + } + catch (ArgumentException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + } + + private bool TableHasSingleColumnPrimaryKey(string tableName, string columnName) + { + if (_connection == null) + { + return false; + } + + using var columnsDocument = JsonDocument.Parse(_connection.GetTableColumnsJson(tableName)); + if (columnsDocument.RootElement.ValueKind != JsonValueKind.Array) + { + return false; + } + + var primaryKeyCount = 0; + var matchedPrimaryKey = false; + foreach (var column in columnsDocument.RootElement.EnumerateArray()) + { + if (!JsonBoolEquals(column, "primary_key", expected: true)) + { + continue; + } + + primaryKeyCount++; + if (TryGetNonEmptyJsonString(column, "name", out var name) && + IdentifiersEqual(name, columnName)) + { + matchedPrimaryKey = true; + } + } + + return primaryKeyCount == 1 && matchedPrimaryKey; + } + + private static bool JsonBoolEquals(JsonElement element, string propertyName, bool expected) + { + return element.TryGetProperty(propertyName, out var property) && + property.ValueKind is JsonValueKind.True or JsonValueKind.False && + property.GetBoolean() == expected; + } + + private static bool TryGetNonEmptyJsonString( + JsonElement element, + string propertyName, + [NotNullWhen(true)] out string? value) + { + value = null; + if (!element.TryGetProperty(propertyName, out var property) || + property.ValueKind != JsonValueKind.String) + { + return false; + } + + value = property.GetString(); + return !string.IsNullOrWhiteSpace(value); + } + + private static bool TryParseSingleTablePrimaryKeyEquality( + string sql, + int parameterIndex1Based, + [NotNullWhen(true)] out string? sourceTable, + [NotNullWhen(true)] out string? sourceColumn) + { + sourceTable = null; + sourceColumn = null; + var tokens = TokenizeSqlShape(sql); + var whereIndex = IndexOfTopLevelKeyword(tokens, "where", start: 0, end: tokens.Count); + if (whereIndex < 0 || + !HasSingleTopLevelTableSource(tokens, whereIndex) || + !TryGetSingleTopLevelTableName(tokens, whereIndex, out sourceTable)) + { + return false; + } + + var start = whereIndex + 1; + var end = FindWhereClauseEnd(tokens, start); + TrimTrivia(tokens, ref start, ref end); + TrimWrappingParentheses(tokens, ref start, ref end); + + return TryMatchColumnParameterEquality( + tokens, + start, + end, + parameterIndex1Based, + out sourceColumn); + } + + private static int IndexOfTopLevelKeyword( + List tokens, + string keyword, + int start, + int end) + { + var depth = 0; + for (var i = start; i < end; i++) + { + var token = tokens[i]; + if (token.Kind == SqlShapeTokenKind.OpenParen) + { + depth++; + continue; + } + + if (token.Kind == SqlShapeTokenKind.CloseParen) + { + depth = Math.Max(0, depth - 1); + continue; + } + + if (depth == 0 && IsKeyword(token, keyword)) + { + return i; + } + } + + return -1; + } + + private static bool HasSingleTopLevelTableSource(List tokens, int whereIndex) + { + var fromIndex = IndexOfTopLevelKeyword(tokens, "from", start: 0, end: whereIndex); + if (fromIndex < 0) + { + return false; + } + + var depth = 0; + for (var i = fromIndex + 1; i < whereIndex; i++) + { + var token = tokens[i]; + if (token.Kind == SqlShapeTokenKind.OpenParen) + { + if (depth == 0) + { + return false; + } + + depth++; + continue; + } + + if (token.Kind == SqlShapeTokenKind.CloseParen) + { + depth = Math.Max(0, depth - 1); + continue; + } + + if (depth != 0) + { + continue; + } + + if (token.Kind == SqlShapeTokenKind.Comma || + IsKeyword(token, "join")) + { + return false; + } + } + + return true; + } + + private static bool TryGetSingleTopLevelTableName( + List tokens, + int whereIndex, + [NotNullWhen(true)] out string? tableName) + { + tableName = null; + var fromIndex = IndexOfTopLevelKeyword(tokens, "from", start: 0, end: whereIndex); + if (fromIndex < 0 || fromIndex + 1 >= whereIndex) + { + return false; + } + + var tableToken = tokens[fromIndex + 1]; + if (tableToken.Kind != SqlShapeTokenKind.Identifier) + { + return false; + } + + if (fromIndex + 3 < whereIndex && + tokens[fromIndex + 2].Kind == SqlShapeTokenKind.Dot && + tokens[fromIndex + 3].Kind == SqlShapeTokenKind.Identifier) + { + // GetTableColumnsJson is table-name scoped today; keep this + // conservative rather than guessing how to resolve schemas. + return false; + } + + tableName = tableToken.Text; + return !string.IsNullOrWhiteSpace(tableName); + } + + private static int FindWhereClauseEnd(List tokens, int start) + { + var depth = 0; + for (var i = start; i < tokens.Count; i++) + { + var token = tokens[i]; + if (token.Kind == SqlShapeTokenKind.OpenParen) + { + depth++; + continue; + } + + if (token.Kind == SqlShapeTokenKind.CloseParen) + { + depth = Math.Max(0, depth - 1); + continue; + } + + if (depth != 0) + { + continue; + } + + if (token.Kind == SqlShapeTokenKind.Semicolon || + IsKeyword(token, "group") || + IsKeyword(token, "order") || + IsKeyword(token, "limit") || + IsKeyword(token, "offset") || + IsKeyword(token, "union") || + IsKeyword(token, "except") || + IsKeyword(token, "intersect")) + { + return i; + } + } + + return tokens.Count; + } + + private static void TrimTrivia(List tokens, ref int start, ref int end) + { + while (start < end && tokens[start].Kind == SqlShapeTokenKind.Semicolon) + { + start++; + } + + while (end > start && tokens[end - 1].Kind == SqlShapeTokenKind.Semicolon) + { + end--; + } + } + + private static void TrimWrappingParentheses(List tokens, ref int start, ref int end) + { + while (end - start >= 2 && + tokens[start].Kind == SqlShapeTokenKind.OpenParen && + tokens[end - 1].Kind == SqlShapeTokenKind.CloseParen && + MatchingCloseParenthesis(tokens, start, end) == end - 1) + { + start++; + end--; + TrimTrivia(tokens, ref start, ref end); + } + } + + private static int MatchingCloseParenthesis(List tokens, int openIndex, int end) + { + var depth = 0; + for (var i = openIndex; i < end; i++) + { + if (tokens[i].Kind == SqlShapeTokenKind.OpenParen) + { + depth++; + } + else if (tokens[i].Kind == SqlShapeTokenKind.CloseParen) + { + depth--; + if (depth == 0) + { + return i; + } + } + } + + return -1; + } + + private static bool TryMatchColumnParameterEquality( + List tokens, + int start, + int end, + int parameterIndex1Based, + [NotNullWhen(true)] out string? sourceColumn) + { + sourceColumn = null; + var equalsIndex = -1; + for (var i = start; i < end; i++) + { + if (tokens[i].Kind != SqlShapeTokenKind.Equals) + { + continue; + } + + if (equalsIndex >= 0) + { + return false; + } + + equalsIndex = i; + } + + if (equalsIndex < 0) + { + return false; + } + + if (TryGetColumnReferenceName(tokens, start, equalsIndex, out var leftColumn) && + IsParameterReference(tokens, equalsIndex + 1, end, parameterIndex1Based)) + { + sourceColumn = leftColumn; + return true; + } + + if (IsParameterReference(tokens, start, equalsIndex, parameterIndex1Based) && + TryGetColumnReferenceName(tokens, equalsIndex + 1, end, out var rightColumn)) + { + sourceColumn = rightColumn; + return true; + } + + return false; + } + + private static bool TryGetColumnReferenceName( + List tokens, + int start, + int end, + [NotNullWhen(true)] out string? sourceColumn) + { + sourceColumn = null; + var length = end - start; + if (length == 1) + { + if (tokens[start].Kind != SqlShapeTokenKind.Identifier) + { + return false; + } + + sourceColumn = tokens[start].Text; + return !string.IsNullOrWhiteSpace(sourceColumn); + } + + if (length == 3 && + tokens[start].Kind == SqlShapeTokenKind.Identifier && + tokens[start + 1].Kind == SqlShapeTokenKind.Dot && + tokens[start + 2].Kind == SqlShapeTokenKind.Identifier) + { + sourceColumn = tokens[start + 2].Text; + return !string.IsNullOrWhiteSpace(sourceColumn); + } + + return false; + } + + private static bool IsParameterReference( + List tokens, + int start, + int end, + int parameterIndex1Based) + { + return end - start == 1 && + tokens[start].Kind == SqlShapeTokenKind.Parameter && + tokens[start].ParameterIndex == parameterIndex1Based; + } + + private static bool IsKeyword(SqlShapeToken token, string keyword) + { + return token.Kind == SqlShapeTokenKind.Identifier && + string.Equals(token.Text, keyword, StringComparison.OrdinalIgnoreCase); + } + + private static bool IdentifiersEqual(string left, string right) + { + return string.Equals(left, right, StringComparison.OrdinalIgnoreCase); + } + + private static List TokenizeSqlShape(string sql) + { + var tokens = new List(); + for (var i = 0; i < sql.Length;) + { + var ch = sql[i]; + if (char.IsWhiteSpace(ch)) + { + i++; + continue; + } + + if (ch == '-' && i + 1 < sql.Length && sql[i + 1] == '-') + { + i += 2; + while (i < sql.Length && sql[i] != '\n') + { + i++; + } + continue; + } + + if (ch == '/' && i + 1 < sql.Length && sql[i + 1] == '*') + { + i += 2; + while (i + 1 < sql.Length && (sql[i] != '*' || sql[i + 1] != '/')) + { + i++; + } + i = Math.Min(sql.Length, i + 2); + continue; + } + + if (ch == '\'') + { + i = SkipQuotedString(sql, i, '\''); + continue; + } + + if (ch == '"') + { + var (identifier, next) = ReadDelimitedIdentifier(sql, i, '"', '"'); + tokens.Add(new SqlShapeToken(SqlShapeTokenKind.Identifier, identifier)); + i = next; + continue; + } + + if (ch == '[') + { + var (identifier, next) = ReadDelimitedIdentifier(sql, i, '[', ']'); + tokens.Add(new SqlShapeToken(SqlShapeTokenKind.Identifier, identifier)); + i = next; + continue; + } + + if (IsIdentifierStart(ch)) + { + var start = i; + i++; + while (i < sql.Length && IsIdentifierPart(sql[i])) + { + i++; + } + + tokens.Add(new SqlShapeToken(SqlShapeTokenKind.Identifier, sql[start..i])); + continue; + } + + if (ch == '$' && i + 1 < sql.Length && char.IsAsciiDigit(sql[i + 1])) + { + var start = i + 1; + i += 2; + while (i < sql.Length && char.IsAsciiDigit(sql[i])) + { + i++; + } + + if (int.TryParse(sql[start..i], NumberStyles.None, CultureInfo.InvariantCulture, out var index)) + { + tokens.Add(new SqlShapeToken(SqlShapeTokenKind.Parameter, parameterIndex: index)); + } + continue; + } + + tokens.Add(ch switch + { + '=' => new SqlShapeToken(SqlShapeTokenKind.Equals), + '.' => new SqlShapeToken(SqlShapeTokenKind.Dot), + ',' => new SqlShapeToken(SqlShapeTokenKind.Comma), + '(' => new SqlShapeToken(SqlShapeTokenKind.OpenParen), + ')' => new SqlShapeToken(SqlShapeTokenKind.CloseParen), + ';' => new SqlShapeToken(SqlShapeTokenKind.Semicolon), + _ => new SqlShapeToken(SqlShapeTokenKind.Other) + }); + i++; + } + + return tokens; + } + + private static int SkipQuotedString(string sql, int start, char quote) + { + var i = start + 1; + while (i < sql.Length) + { + if (sql[i] != quote) + { + i++; + continue; + } + + if (i + 1 < sql.Length && sql[i + 1] == quote) + { + i += 2; + continue; + } + + return i + 1; + } + + return sql.Length; + } + + private static (string Identifier, int Next) ReadDelimitedIdentifier( + string sql, + int start, + char open, + char close) + { + var value = new StringBuilder(); + var i = start + 1; + while (i < sql.Length) + { + if (sql[i] != close) + { + value.Append(sql[i]); + i++; + continue; + } + + if (open == close && i + 1 < sql.Length && sql[i + 1] == close) + { + value.Append(close); + i += 2; + continue; + } + + return (value.ToString(), i + 1); + } + + return (value.ToString(), sql.Length); + } + + private static bool IsIdentifierStart(char ch) + { + return ch == '_' || + (ch >= 'A' && ch <= 'Z') || + (ch >= 'a' && ch <= 'z'); + } + + private static bool IsIdentifierPart(char ch) + { + return IsIdentifierStart(ch) || + (ch >= '0' && ch <= '9'); + } + private static decimal NormalizeDecimalScale(DbParameter parameter, decimal value) { if (parameter is not DecentDBParameter decentParameter || !decentParameter.HasScale) @@ -912,6 +1530,11 @@ private int ExecuteSingleNonQuery() return pragmaRowsAffected; } + if (TryExecuteCachedSingleInt64NonQuery(out var singleInt64RowsAffected)) + { + return singleInt64RowsAffected; + } + if (TryExecuteCachedInt64TextFloat64NonQuery(out var fastRowsAffected)) { return fastRowsAffected; @@ -1016,6 +1639,88 @@ private bool TryExecuteInt64TextFloat64SingleNonQuery( out rowsAffected); } + private bool TryExecuteCachedSingleInt64NonQuery(out int rowsAffected) + { + rowsAffected = 0; + if (_connection == null || + _connection.IsSqlObservationEnabled || + !TryGetCachedSingleInt64NonQueryPlan(out var plan)) + { + return false; + } + + for (var attempt = 0; ; attempt++) + { + var stmt = EnsurePreparedStatement(plan.Sql, resetForExecution: false); + var rawValue = plan.Parameter.Value; + if (rawValue == null || + rawValue == DBNull.Value || + !TryGetOptimizedInt64(plan.Parameter, rawValue, out var intValue)) + { + return false; + } + + try + { + rowsAffected = checked((int)stmt.RebindInt64Execute(intValue)); + return true; + } + catch (DecentDBException ex) + { + InvalidatePreparedStatement(discardFromConnectionCache: true); + if (attempt == 0 && IsSchemaChangedPreparedStatementError(ex)) + { + _connection.ClearPreparedStatementCacheForSchemaChange(); + continue; + } + + throw; + } + catch + { + InvalidatePreparedStatement(discardFromConnectionCache: true); + throw; + } + } + } + + private bool TryGetCachedSingleInt64NonQueryPlan( + [NotNullWhen(true)] out SingleInt64NonQueryPlan? plan) + { + if (_cachedSingleInt64NonQueryPlan != null && + _cachedSingleInt64NonQueryPlan.Matches(_commandText, _parameters)) + { + plan = _cachedSingleInt64NonQueryPlan; + return true; + } + + plan = null; + if (_parameters.Count != 1 || GetSplitStatements().Count != 1) + { + return false; + } + + var (sql, paramMap, needsOffsetClamp) = GetRewrittenSqlAndParameters(); + if (needsOffsetClamp || + paramMap.Count != 1 || + !paramMap.TryGetValue(1, out var parameter)) + { + return false; + } + + var value = parameter.Value; + if (value == null || + value == DBNull.Value || + !TryGetOptimizedInt64(parameter, value, out _)) + { + return false; + } + + plan = new SingleInt64NonQueryPlan(_commandText, sql, _parameters[0], parameter); + _cachedSingleInt64NonQueryPlan = plan; + return true; + } + private bool TryExecuteCachedInt64TextFloat64NonQuery(out int rowsAffected) { rowsAffected = 0; @@ -1189,6 +1894,7 @@ private bool TryExecuteTypedSingleNonQuery( Span f64Values = stackalloc double[paramMap.Count]; byte[]? text0 = null; byte[]? text1 = null; + byte[]? text2 = null; var i64Count = 0; var f64Count = 0; var textCount = 0; @@ -1213,6 +1919,13 @@ private bool TryExecuteTypedSingleNonQuery( continue; } + if (TryGetOptimizedBoolean(parameter, value, out var boolValue)) + { + signatureUtf8[ordinal - 1] = (byte)'b'; + i64Values[i64Count++] = boolValue ? 1 : 0; + continue; + } + if (TryGetOptimizedFloat64(value, out var floatValue)) { signatureUtf8[ordinal - 1] = (byte)'f'; @@ -1231,6 +1944,10 @@ private bool TryExecuteTypedSingleNonQuery( { text1 = textBytes; } + else if (textCount == 2) + { + text2 = textBytes; + } else { return false; @@ -1250,6 +1967,7 @@ private bool TryExecuteTypedSingleNonQuery( f64Values[..f64Count], text0, text1, + text2, textCount)); return true; } @@ -1296,6 +2014,26 @@ private static bool TryGetOptimizedInt64( } } + private static bool TryGetOptimizedBoolean( + DbParameter parameter, + object value, + out bool result) + { + result = default; + if (parameter.DbType == DbType.Guid) + { + return false; + } + + if (value is bool boolValue) + { + result = boolValue; + return true; + } + + return false; + } + private static bool TryGetOptimizedFloat64(object value, out double result) { switch (value) @@ -1503,6 +2241,137 @@ private static bool TryParsePragma(string sql, out string pragmaName, out string return true; } + private readonly struct SqlShapeToken + { + public SqlShapeToken( + SqlShapeTokenKind kind, + string text = "", + int parameterIndex = 0) + { + Kind = kind; + Text = text; + ParameterIndex = parameterIndex; + } + + public SqlShapeTokenKind Kind { get; } + + public string Text { get; } + + public int ParameterIndex { get; } + } + + private enum SqlShapeTokenKind + { + Identifier, + Parameter, + Equals, + Dot, + Comma, + OpenParen, + CloseParen, + Semicolon, + Other + } + + private sealed class SingleRowReadPlan + { + private readonly DbParameter[] _parameterRefs; + private readonly string?[] _parameterNames; + + public SingleRowReadPlan( + string sourceSql, + string sql, + int parameterIndex1Based, + IReadOnlyList parameters, + bool knownMaxOneRow) + { + SourceSql = sourceSql; + Sql = sql; + ParameterIndex1Based = parameterIndex1Based; + KnownMaxOneRow = knownMaxOneRow; + _parameterRefs = new DbParameter[parameters.Count]; + _parameterNames = new string?[parameters.Count]; + for (var i = 0; i < parameters.Count; i++) + { + _parameterRefs[i] = parameters[i]; + _parameterNames[i] = parameters[i].ParameterName; + } + } + + public string SourceSql { get; } + + public string Sql { get; } + + public int ParameterIndex1Based { get; } + + public bool KnownMaxOneRow { get; } + + public bool Matches( + string sourceSql, + string sql, + int parameterIndex1Based, + IReadOnlyList parameters) + { + if (!string.Equals(SourceSql, sourceSql, StringComparison.Ordinal) || + !string.Equals(Sql, sql, StringComparison.Ordinal) || + ParameterIndex1Based != parameterIndex1Based || + _parameterRefs.Length != parameters.Count || + _parameterNames.Length != parameters.Count) + { + return false; + } + + for (var i = 0; i < parameters.Count; i++) + { + if (!ReferenceEquals(_parameterRefs[i], parameters[i]) || + !string.Equals(_parameterNames[i], parameters[i].ParameterName, StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + } + + private sealed class SingleInt64NonQueryPlan + { + public SingleInt64NonQueryPlan( + string sourceSql, + string sql, + DecentDBParameter collectionParameter, + DbParameter parameter) + { + SourceSql = sourceSql; + Sql = sql; + CollectionParameter = collectionParameter; + CollectionParameterName = collectionParameter.ParameterName; + Parameter = parameter; + ParameterName = parameter.ParameterName; + } + + public string SourceSql { get; } + + public string Sql { get; } + + public DbParameter Parameter { get; } + + private DecentDBParameter CollectionParameter { get; } + + private string CollectionParameterName { get; } + + private string ParameterName { get; } + + public bool Matches(string commandText, IReadOnlyList parameters) + { + return parameters.Count == 1 && + string.Equals(SourceSql, commandText, StringComparison.Ordinal) && + ReferenceEquals(CollectionParameter, parameters[0]) && + string.Equals(CollectionParameterName, parameters[0].ParameterName, StringComparison.Ordinal) && + string.Equals(ParameterName, Parameter.ParameterName, StringComparison.Ordinal); + } + } + private sealed class Int64TextFloat64NonQueryPlan { public Int64TextFloat64NonQueryPlan( diff --git a/bindings/dotnet/src/DecentDB.AdoNet/DecentDBConnection.cs b/bindings/dotnet/src/DecentDB.AdoNet/DecentDBConnection.cs index b5d309cc..85189c59 100644 --- a/bindings/dotnet/src/DecentDB.AdoNet/DecentDBConnection.cs +++ b/bindings/dotnet/src/DecentDB.AdoNet/DecentDBConnection.cs @@ -250,6 +250,24 @@ public string ListTriggersJson() public static uint AbiVersion() => Native.DecentDB.AbiVersion(); public static string EngineVersion() => Native.DecentDB.EngineVersion(); + /// + /// Executes many rows against one prepared statement using DecentDB's typed native batch path. + /// Signature characters are i for INT64, b for BOOLEAN, f for FLOAT64, + /// and t for TEXT. BOOLEAN values are supplied in as + /// 0 for false and non-zero for true. + /// + public long ExecutePreparedBatchTyped( + string sql, + ReadOnlySpan signatureUtf8, + int rowCount, + ReadOnlySpan i64Values, + ReadOnlySpan f64Values, + IReadOnlyList textValues) + { + var statement = GetOrAddPreparedStatement(sql); + return statement.ExecuteBatchTyped(signatureUtf8, rowCount, i64Values, f64Values, textValues); + } + /// /// Deletes the database file and all associated sidecar files (WAL, SHM, coordination). /// This operation ignores missing files — each path is deleted if present, diff --git a/bindings/dotnet/src/DecentDB.AdoNet/DecentDBDataReader.cs b/bindings/dotnet/src/DecentDB.AdoNet/DecentDBDataReader.cs index dd19ba9b..42254efa 100644 --- a/bindings/dotnet/src/DecentDB.AdoNet/DecentDBDataReader.cs +++ b/bindings/dotnet/src/DecentDB.AdoNet/DecentDBDataReader.cs @@ -1,5 +1,6 @@ using System; using System.Collections; +using System.Collections.Generic; using System.Data; using System.Data.Common; using System.Threading; @@ -17,23 +18,46 @@ public sealed class DecentDBDataReader : DbDataReader private bool _hasRows; private bool _isClosed; private int _recordsAffected; + private int _fieldCount = -1; + private string[]? _columnNames; + private Dictionary? _ordinalLookup; private readonly int _initialStepResult; + private readonly bool _knownMaxOneRow; private bool _initialStepConsumed; + private bool _exhausted; - internal DecentDBDataReader(DecentDBCommand command, PreparedStatement statement, int initialStepResult, SqlObservation? observation) + internal DecentDBDataReader( + DecentDBCommand command, + PreparedStatement statement, + int initialStepResult, + SqlObservation? observation, + bool knownMaxOneRow = false) { _command = command; _statement = statement; _initialStepResult = initialStepResult; + _knownMaxOneRow = knownMaxOneRow; _sqlObservation = observation; _hasRows = initialStepResult == 1; + _fieldCount = statement.CapturedRowColumnCount; _recordsAffected = -1; } public override int Depth => 0; - public override int FieldCount => _statement.ColumnCount; + public override int FieldCount + { + get + { + if (_fieldCount < 0) + { + _fieldCount = _statement.ColumnCount; + } + + return _fieldCount; + } + } public override bool HasRows => _hasRows; @@ -87,7 +111,7 @@ private long GetInt64Value(int ordinal) public override string GetName(int ordinal) { - return _statement.ColumnName(ordinal); + return GetColumnName(ordinal); } public override string GetDataTypeName(int ordinal) @@ -347,19 +371,62 @@ public override bool IsDBNull(int ordinal) return _statement.IsNull(ordinal); } - public override int GetOrdinal(string name) + private string GetColumnName(int ordinal) { - var count = _statement.ColumnCount; - for (int i = 0; i < count; i++) + var names = _columnNames; + if (names == null) { - if (_statement.ColumnName(i).Equals(name, StringComparison.OrdinalIgnoreCase)) + var count = FieldCount; + if ((uint)ordinal >= (uint)count) { - return i; + return _statement.ColumnName(ordinal); } + + names = new string[count]; + _columnNames = names; + } + else if ((uint)ordinal >= (uint)names.Length) + { + return _statement.ColumnName(ordinal); + } + + var name = names[ordinal]; + if (name != null) + { + return name; + } + + name = _statement.ColumnName(ordinal); + names[ordinal] = name; + return name; + } + + public override int GetOrdinal(string name) + { + _ordinalLookup ??= BuildOrdinalLookup(); + if (_ordinalLookup.TryGetValue(name, out var ordinal)) + { + return ordinal; } throw new IndexOutOfRangeException($"Column '{name}' not found"); } + private Dictionary BuildOrdinalLookup() + { + var lookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + var count = FieldCount; + for (var i = 0; i < count; i++) + { + var columnName = GetColumnName(i); + if (!lookup.ContainsKey(columnName)) + { + lookup.Add(columnName, i); + } + } + + return lookup; + } + public override int GetValues(object[] values) { var count = Math.Min(FieldCount, values.Length); @@ -386,7 +453,18 @@ public override bool Read() throw new DecentDBException(_initialStepResult, "Step failed", _command.CommandText); } - return _initialStepResult == 1; + var hasRow = _initialStepResult == 1; + if (!hasRow || _knownMaxOneRow) + { + _exhausted = true; + } + + return hasRow; + } + + if (_exhausted) + { + return false; } var result = _statement.Step(); @@ -397,7 +475,13 @@ public override bool Read() throw ex; } - return result == 1; + var hasNextRow = result == 1; + if (!hasNextRow) + { + _exhausted = true; + } + + return hasNextRow; } public override Task ReadAsync(CancellationToken cancellationToken) @@ -425,14 +509,17 @@ public override void Close() if (_isClosed) return; _isClosed = true; - CompleteSqlObservationOnce(exception: null); + if (_sqlObservation != null) + { + CompleteSqlObservationOnce(exception: null); + } _command.FinalizeStatement(); } private void CompleteSqlObservationOnce(Exception? exception) { - if (_sqlObservationCompleted) return; if (_sqlObservation == null) return; + if (_sqlObservationCompleted) return; _sqlObservationCompleted = true; _command.OwnerConnection.CompleteSqlObservation(_sqlObservation, _statement.RowsAffected, exception); diff --git a/bindings/dotnet/src/DecentDB.AdoNet/README.md b/bindings/dotnet/src/DecentDB.AdoNet/README.md index 4ef2868f..9a2bd0f2 100644 --- a/bindings/dotnet/src/DecentDB.AdoNet/README.md +++ b/bindings/dotnet/src/DecentDB.AdoNet/README.md @@ -56,6 +56,97 @@ helper: stmt.Reset().ClearBindings().BindInt64(1, id).StepRowsAffected(); ``` +## Performance checklist + +For hot ADO.NET insert, update, delete, or point-read loops, create one command, +create its parameters once, call `Prepare()`, and mutate `DbParameter.Value` +inside the loop. Allocating a new command and new parameter objects for every +row can dominate small-statement benchmarks. + +```csharp +using System.Data; +using DecentDB.AdoNet; + +using var connection = new DecentDBConnection(connectionString); +connection.Open(); + +using var transaction = connection.BeginTransaction(); +using var command = connection.CreateCommand(); +command.Transaction = transaction; +command.CommandText = "INSERT INTO events (id, category) VALUES (@id, @category)"; + +var id = command.CreateParameter(); +id.ParameterName = "@id"; +id.DbType = DbType.Int64; +command.Parameters.Add(id); + +var category = command.CreateParameter(); +category.ParameterName = "@category"; +category.DbType = DbType.String; +command.Parameters.Add(category); + +command.Prepare(); + +foreach (var row in rows) +{ + id.Value = row.Id; + category.Value = row.Category; + command.ExecuteNonQuery(); +} + +transaction.Commit(); +connection.Checkpoint(); +``` + +For hot homogeneous import batches, `DecentDBConnection.ExecutePreparedBatchTyped` +uses the native typed batch path while keeping ADO.NET connection management. The +SQL must use positional parameters (`$1`, `$2`, ...), and the signature is a +NUL-terminated ASCII string where `i` = INT64, `b` = BOOLEAN encoded in the +INT64 array as `0` or non-zero, `f` = FLOAT64, and `t` = UTF-8 TEXT. Arrays are +flat and row-major for each type: + +```csharp +long affected = connection.ExecutePreparedBatchTyped( + "INSERT INTO events (id, category, amount, active) VALUES ($1, $2, $3, $4)", + Encoding.ASCII.GetBytes("itfb\0"), + rowCount: 3, + i64Values: new long[] { 1, 1, 2, 0, 3, 1 }, + f64Values: new double[] { 10.5, 20.0, 30.25 }, + textValues: new[] + { + Encoding.UTF8.GetBytes("alpha"), + Encoding.UTF8.GetBytes("beta"), + Encoding.UTF8.GetBytes("gamma"), + }); +``` + +This is an advanced import path. For ordinary application code, prefer prepared +`DbCommand` loops unless profiling shows binding overhead dominates. + +Use `ExplainQuery(...)` before assuming a slow query is ADO.NET overhead: + +```csharp +var plan = connection.ExplainQuery( + "SELECT category FROM events WHERE id = @id", + analyze: true); +Console.WriteLine(plan.Text); +``` + +When comparing DecentDB to SQLite: + +- use Release builds, warm up the JIT, repeat each case, and alternate engine + order between runs +- keep durability settings equivalent and label any relaxed-durability run, for + example SQLite `synchronous=NORMAL` versus DecentDB `async_commit` +- reuse prepared commands and parameters for both providers +- avoid no-result `LIKE` probes unless that is the intended workload; they + mostly measure planning and binding overhead +- time materialized-summary maintenance if the measured query reads a summary + table instead of computing the original aggregate + +The canonical .NET CRM comparison benchmark lives at +[`bindings/dotnet/benchmarks/DecentDB.CrmComparison/`](../../benchmarks/DecentDB.CrmComparison/). + ## Cleanup helper Use `DecentDBConnection.DeleteDatabaseFiles(path)` to safely delete the database file and all sidecar files (`.wal`, `-wal`, `-shm`, `.coord`) in the correct order. This prevents stale WAL or coordination artifacts when recreating databases. diff --git a/bindings/dotnet/src/DecentDB.Native/DecentDB.cs b/bindings/dotnet/src/DecentDB.Native/DecentDB.cs index f6c36cf4..ef78d0ad 100644 --- a/bindings/dotnet/src/DecentDB.Native/DecentDB.cs +++ b/bindings/dotnet/src/DecentDB.Native/DecentDB.cs @@ -1330,6 +1330,25 @@ public long ExecuteBatchTypedOneRow( byte[]? text0, byte[]? text1, int textCount) + { + return ExecuteBatchTypedOneRow( + signatureUtf8, + i64Values, + f64Values, + text0, + text1, + null, + textCount); + } + + public long ExecuteBatchTypedOneRow( + ReadOnlySpan signatureUtf8, + ReadOnlySpan i64Values, + ReadOnlySpan f64Values, + byte[]? text0, + byte[]? text1, + byte[]? text2, + int textCount) { InvalidateRowViewCache(); unsafe @@ -1347,6 +1366,7 @@ public long ExecuteBatchTypedOneRow( null, text0, text1, + text2, textCount); } else @@ -1359,6 +1379,7 @@ public long ExecuteBatchTypedOneRow( pF64, text0, text1, + text2, textCount); } } @@ -1375,6 +1396,7 @@ public long ExecuteBatchTypedOneRow( null, text0, text1, + text2, textCount); } else @@ -1387,6 +1409,7 @@ public long ExecuteBatchTypedOneRow( pF64, text0, text1, + text2, textCount); } } @@ -1398,12 +1421,199 @@ public long ExecuteBatchTypedOneRow( } } + public long ExecuteBatchTyped( + ReadOnlySpan signatureUtf8, + int rowCount, + ReadOnlySpan i64Values, + ReadOnlySpan f64Values, + IReadOnlyList textValues) + { + ArgumentOutOfRangeException.ThrowIfNegative(rowCount); + if (signatureUtf8.IsEmpty || signatureUtf8[^1] != 0) + { + throw new ArgumentException("Signature must be NUL-terminated.", nameof(signatureUtf8)); + } + + var i64PerRow = 0; + var f64PerRow = 0; + var textPerRow = 0; + for (var i = 0; i < signatureUtf8.Length - 1; i++) + { + switch (signatureUtf8[i]) + { + case (byte)'i': + case (byte)'b': + i64PerRow++; + break; + case (byte)'f': + f64PerRow++; + break; + case (byte)'t': + textPerRow++; + break; + default: + throw new ArgumentException( + $"Unsupported typed batch signature character '{(char)signatureUtf8[i]}'.", + nameof(signatureUtf8)); + } + } + + if (i64Values.Length != checked(rowCount * i64PerRow)) + { + throw new ArgumentException("INT64/BOOLEAN value count does not match signature and row count.", nameof(i64Values)); + } + if (f64Values.Length != checked(rowCount * f64PerRow)) + { + throw new ArgumentException("FLOAT64 value count does not match signature and row count.", nameof(f64Values)); + } + if (textValues.Count != checked(rowCount * textPerRow)) + { + throw new ArgumentException("TEXT value count does not match signature and row count.", nameof(textValues)); + } + + if (rowCount == 0) + { + return 0; + } + + InvalidateRowViewCache(); + unsafe + { + fixed (byte* pSignature = signatureUtf8) + { + if (i64Values.IsEmpty) + { + if (f64Values.IsEmpty) + { + return ExecuteBatchTypedCore( + pSignature, + checked((nuint)rowCount), + null, + null, + textValues); + } + + fixed (double* pF64 = f64Values) + { + return ExecuteBatchTypedCore( + pSignature, + checked((nuint)rowCount), + null, + pF64, + textValues); + } + } + + fixed (long* pI64 = i64Values) + { + if (f64Values.IsEmpty) + { + return ExecuteBatchTypedCore( + pSignature, + checked((nuint)rowCount), + pI64, + null, + textValues); + } + + fixed (double* pF64 = f64Values) + { + return ExecuteBatchTypedCore( + pSignature, + checked((nuint)rowCount), + pI64, + pF64, + textValues); + } + } + } + } + } + + private unsafe long ExecuteBatchTypedCore( + byte* signatureUtf8, + nuint rowCount, + long* valuesI64, + double* valuesF64, + IReadOnlyList textValues) + { + const int StackallocTextPointerLimit = 8192; + if (textValues.Count == 0) + { + var res = _db.RecordStatus( + DecentDBNativeUnsafe.ddb_stmt_execute_batch_typed( + Handle, + rowCount, + signatureUtf8, + valuesI64, + valuesF64, + null, + null, + out var affected)); + if (res != 0) + { + throw new DecentDBException(_db.LastErrorCode, _db.LastErrorMessage, _sql); + } + + return (long)affected; + } + + if (textValues.Count > StackallocTextPointerLimit) + { + throw new ArgumentException( + $"Typed batch text value count must be {StackallocTextPointerLimit} or fewer per call.", + nameof(textValues)); + } + + var handles = new GCHandle[textValues.Count]; + byte** textPtrs = stackalloc byte*[textValues.Count]; + nuint* textLens = stackalloc nuint[textValues.Count]; + try + { + for (var i = 0; i < textValues.Count; i++) + { + var value = textValues[i] ?? throw new ArgumentException("TEXT batch values cannot be null.", nameof(textValues)); + handles[i] = GCHandle.Alloc(value, GCHandleType.Pinned); + textPtrs[i] = (byte*)handles[i].AddrOfPinnedObject(); + textLens[i] = checked((nuint)value.Length); + } + + var res = _db.RecordStatus( + DecentDBNativeUnsafe.ddb_stmt_execute_batch_typed( + Handle, + rowCount, + signatureUtf8, + valuesI64, + valuesF64, + textPtrs, + textLens, + out var affected)); + if (res != 0) + { + throw new DecentDBException(_db.LastErrorCode, _db.LastErrorMessage, _sql); + } + + return (long)affected; + } + finally + { + for (var i = 0; i < handles.Length; i++) + { + if (handles[i].IsAllocated) + { + handles[i].Free(); + } + } + } + } + private unsafe long ExecuteBatchTypedOneRowCore( byte* signatureUtf8, long* valuesI64, double* valuesF64, byte[]? text0, byte[]? text1, + byte[]? text2, int textCount) { unsafe @@ -1484,6 +1694,38 @@ private unsafe long ExecuteBatchTypedOneRowCore( return (long)affected; } } + case 3: + { + fixed (byte* pText0 = text0) + fixed (byte* pText1 = text1) + fixed (byte* pText2 = text2) + { + byte** textPtrs = stackalloc byte*[3]; + nuint* textLens = stackalloc nuint[3]; + textPtrs[0] = pText0; + textPtrs[1] = pText1; + textPtrs[2] = pText2; + textLens[0] = (nuint)(text0?.Length ?? 0); + textLens[1] = (nuint)(text1?.Length ?? 0); + textLens[2] = (nuint)(text2?.Length ?? 0); + var res = _db.RecordStatus( + DecentDBNativeUnsafe.ddb_stmt_execute_batch_typed( + Handle, + 1, + signatureUtf8, + valuesI64, + valuesF64, + textPtrs, + textLens, + out var affected)); + if (res != 0) + { + throw new DecentDBException(_db.LastErrorCode, _db.LastErrorMessage, _sql); + } + + return (long)affected; + } + } default: throw new ArgumentOutOfRangeException(nameof(textCount)); } @@ -1605,6 +1847,15 @@ private void InvalidateRowViewCache() _currentRowViewsCaptured = false; } + internal int CapturedRowColumnCount + { + get + { + var views = _currentRowViews; + return _currentRowViewsCaptured && views != null ? views.Length : -1; + } + } + private unsafe void CaptureRowViews(IntPtr values, nuint count) { var len = checked((int)count); diff --git a/bindings/dotnet/tests/DecentDB.Tests/BatchOperationTests.cs b/bindings/dotnet/tests/DecentDB.Tests/BatchOperationTests.cs index 29c73edc..5e605820 100644 --- a/bindings/dotnet/tests/DecentDB.Tests/BatchOperationTests.cs +++ b/bindings/dotnet/tests/DecentDB.Tests/BatchOperationTests.cs @@ -1,5 +1,6 @@ using System.Data; using System.Data.Common; +using System.Text; using DecentDB.AdoNet; using Xunit; @@ -294,10 +295,241 @@ public void Batch_UpdateAndDelete_AffectedRowsCount() Assert.Equal(50L, Convert.ToInt64(verify.ExecuteScalar())); } + [Fact] + public void PreparedSingleInt64NonQuery_ValueMutationAndParameterRename_RemainCorrect() + { + using var connection = new DecentDBConnection($"Data Source={_dbPath}"); + connection.Open(); + + using var create = connection.CreateCommand(); + create.CommandText = "CREATE TABLE single_int_fast_path (id INTEGER PRIMARY KEY)"; + create.ExecuteNonQuery(); + + using var insert = connection.CreateCommand(); + insert.CommandText = "INSERT INTO single_int_fast_path (id) VALUES (@id)"; + AddParameter(insert, "@id"); + insert.Prepare(); + + for (int i = 1; i <= 25; i++) + { + insert.Parameters[0].Value = i; + Assert.Equal(1, insert.ExecuteNonQuery()); + } + + insert.Parameters[0].ParameterName = "@renamed"; + insert.CommandText = "INSERT INTO single_int_fast_path (id) VALUES (@renamed)"; + insert.Prepare(); + insert.Parameters[0].Value = 26; + Assert.Equal(1, insert.ExecuteNonQuery()); + + using var delete = connection.CreateCommand(); + delete.CommandText = "DELETE FROM single_int_fast_path WHERE id = @id"; + AddParameter(delete, "@id"); + delete.Prepare(); + delete.Parameters[0].Value = 7; + Assert.Equal(1, delete.ExecuteNonQuery()); + delete.Parameters[0].Value = 99; + Assert.Equal(0, delete.ExecuteNonQuery()); + + using var verify = connection.CreateCommand(); + verify.CommandText = "SELECT COUNT(*) FROM single_int_fast_path"; + Assert.Equal(25L, Convert.ToInt64(verify.ExecuteScalar())); + } + + [Fact] + public void PreparedSingleInt64NonQuery_FallsBackWhenCachedPlanReceivesNonIntValue() + { + using var connection = new DecentDBConnection($"Data Source={_dbPath}"); + connection.Open(); + + using var create = connection.CreateCommand(); + create.CommandText = "CREATE TABLE single_int_fallback (value TEXT)"; + create.ExecuteNonQuery(); + + using var insert = connection.CreateCommand(); + insert.CommandText = "INSERT INTO single_int_fallback (value) VALUES (@value)"; + AddParameter(insert, "@value"); + insert.Prepare(); + + insert.Parameters[0].Value = 42L; + Assert.Equal(1, insert.ExecuteNonQuery()); + + insert.Parameters[0].Value = "forty-three"; + Assert.Equal(1, insert.ExecuteNonQuery()); + + using var verifyCount = connection.CreateCommand(); + verifyCount.CommandText = "SELECT COUNT(*) FROM single_int_fallback"; + Assert.Equal(2L, Convert.ToInt64(verifyCount.ExecuteScalar())); + + using var verifyText = connection.CreateCommand(); + verifyText.CommandText = "SELECT COUNT(*) FROM single_int_fallback WHERE value = 'forty-three'"; + Assert.Equal(1L, Convert.ToInt64(verifyText.ExecuteScalar())); + } + + [Fact] + public void PreparedSingleInt64NonQuery_WithObservation_FiresExecutedEvent() + { + using var connection = new DecentDBConnection($"Data Source={_dbPath}"); + connection.Open(); + + using var create = connection.CreateCommand(); + create.CommandText = "CREATE TABLE observed_single_int (id INTEGER PRIMARY KEY)"; + create.ExecuteNonQuery(); + + SqlExecutedEventArgs? observed = null; + connection.SqlExecuted += (_, args) => + { + if (args.Sql.Contains("observed_single_int", StringComparison.Ordinal)) + { + observed = args; + } + }; + + using var insert = connection.CreateCommand(); + insert.CommandText = "INSERT INTO observed_single_int (id) VALUES (@id)"; + AddParameter(insert, "@id"); + insert.Prepare(); + insert.Parameters[0].Value = 7L; + + Assert.Equal(1, insert.ExecuteNonQuery()); + + Assert.NotNull(observed); + Assert.Equal(1L, observed.RowsAffected); + Assert.Single(observed.Parameters); + Assert.Equal(7L, observed.Parameters[0].Value); + } + + [Fact] + public void PreparedTypedSingleNonQuery_ThreeTextParameters_RoundTrip() + { + using var connection = new DecentDBConnection($"Data Source={_dbPath}"); + connection.Open(); + + using var create = connection.CreateCommand(); + create.CommandText = """ + CREATE TABLE typed_three_text_fast_path ( + id INTEGER PRIMARY KEY, + first_text TEXT NOT NULL, + second_text TEXT NOT NULL, + third_text TEXT NOT NULL, + amount REAL NOT NULL, + active BOOLEAN NOT NULL + ) + """; + create.ExecuteNonQuery(); + + using var insert = connection.CreateCommand(); + insert.CommandText = """ + INSERT INTO typed_three_text_fast_path + (id, first_text, second_text, third_text, amount, active) + VALUES (@id, @first, @second, @third, @amount, @active) + """; + AddParameter(insert, "@id"); + AddParameter(insert, "@first"); + AddParameter(insert, "@second"); + AddParameter(insert, "@third"); + AddParameter(insert, "@amount"); + AddParameter(insert, "@active"); + insert.Parameters[0].DbType = DbType.Int64; + insert.Parameters[1].DbType = DbType.String; + insert.Parameters[2].DbType = DbType.String; + insert.Parameters[3].DbType = DbType.String; + insert.Parameters[4].DbType = DbType.Double; + insert.Parameters[5].DbType = DbType.Boolean; + insert.Prepare(); + + for (int i = 1; i <= 40; i++) + { + insert.Parameters[0].Value = i; + insert.Parameters[1].Value = $"alpha-{i}"; + insert.Parameters[2].Value = i % 3 == 0 ? string.Empty : $"beta-{i}"; + insert.Parameters[3].Value = $"gamma-{i}"; + insert.Parameters[4].Value = i + 0.25d; + insert.Parameters[5].Value = i % 2 == 1; + Assert.Equal(1, insert.ExecuteNonQuery()); + } + + using var verify = connection.CreateCommand(); + verify.CommandText = """ + SELECT first_text, second_text, third_text, amount, active + FROM typed_three_text_fast_path + WHERE id = 39 + """; + using var reader = verify.ExecuteReader(); + Assert.True(reader.Read()); + Assert.Equal("alpha-39", reader.GetString(0)); + Assert.Equal(string.Empty, reader.GetString(1)); + Assert.Equal("gamma-39", reader.GetString(2)); + Assert.Equal(39.25d, reader.GetDouble(3)); + Assert.True(reader.GetBoolean(4)); + Assert.False(reader.Read()); + } + + [Fact] + public void ExecutePreparedBatchTyped_MixedTypes_InsertsAllRows() + { + using var connection = new DecentDBConnection($"Data Source={_dbPath}"); + connection.Open(); + + using var create = connection.CreateCommand(); + create.CommandText = """ + CREATE TABLE typed_batch_api ( + id INTEGER PRIMARY KEY, + label TEXT NOT NULL, + amount REAL NOT NULL, + active BOOLEAN NOT NULL + ) + """; + create.ExecuteNonQuery(); + + using var transaction = connection.BeginTransaction(); + const string insertSql = "INSERT INTO typed_batch_api (id, label, amount, active) VALUES ($1, $2, $3, $4)"; + var affected = connection.ExecutePreparedBatchTyped( + insertSql, + Encoding.ASCII.GetBytes("itfb\0"), + rowCount: 4, + i64Values: new long[] { 1, 1, 2, 0, 3, 1, 4, 0 }, + f64Values: new double[] { 10.5d, 20.25d, 30.75d, 40.125d }, + textValues: new[] + { + Encoding.UTF8.GetBytes("alpha"), + Encoding.UTF8.GetBytes("beta"), + Encoding.UTF8.GetBytes("gamma"), + Encoding.UTF8.GetBytes("delta"), + }); + affected += connection.ExecutePreparedBatchTyped( + insertSql, + Encoding.ASCII.GetBytes("itfb\0"), + rowCount: 2, + i64Values: new long[] { 5, 1, 6, 0 }, + f64Values: new double[] { 50.5d, 60.5d }, + textValues: new[] + { + Encoding.UTF8.GetBytes("epsilon"), + Encoding.UTF8.GetBytes("zeta"), + }); + transaction.Commit(); + + Assert.Equal(6L, affected); + + using var verify = connection.CreateCommand(); + verify.CommandText = """ + SELECT label, amount, active + FROM typed_batch_api + WHERE id = 3 + """; + using var reader = verify.ExecuteReader(); + Assert.True(reader.Read()); + Assert.Equal("gamma", reader.GetString(0)); + Assert.Equal(30.75d, reader.GetDouble(1)); + Assert.True(reader.GetBoolean(2)); + Assert.False(reader.Read()); + } + private static void AddParameter(DbCommand command, string name) { var parameter = command.CreateParameter(); parameter.ParameterName = name; command.Parameters.Add(parameter); } -} \ No newline at end of file +} diff --git a/bindings/go/decentdb-go/decentdb.h b/bindings/go/decentdb-go/decentdb.h index 791ca3b8..86e4edb9 100644 --- a/bindings/go/decentdb-go/decentdb.h +++ b/bindings/go/decentdb-go/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/java/dbeaver-extension/META-INF/MANIFEST.MF b/bindings/java/dbeaver-extension/META-INF/MANIFEST.MF index 1eff5e9c..604e3ff5 100644 --- a/bindings/java/dbeaver-extension/META-INF/MANIFEST.MF +++ b/bindings/java/dbeaver-extension/META-INF/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: DecentDB DBeaver Extension Bundle-SymbolicName: org.jkiss.dbeaver.ext.decentdb;singleton:=true -Bundle-Version: 2.15.0 +Bundle-Version: 2.16.0 Bundle-Activator: org.jkiss.dbeaver.ext.decentdb.DecentDBActivator Bundle-Vendor: DecentDB Contributors Require-Bundle: org.eclipse.core.runtime, @@ -11,5 +11,5 @@ Require-Bundle: org.eclipse.core.runtime, org.jkiss.dbeaver.ext.generic Bundle-RequiredExecutionEnvironment: JavaSE-17 Bundle-ClassPath: ., - lib/decentdb-jdbc-2.15.0.jar + lib/decentdb-jdbc-2.16.0.jar Export-Package: org.jkiss.dbeaver.ext.decentdb.model diff --git a/bindings/java/dbeaver-extension/build.gradle b/bindings/java/dbeaver-extension/build.gradle index 9720e10d..d86f38f1 100644 --- a/bindings/java/dbeaver-extension/build.gradle +++ b/bindings/java/dbeaver-extension/build.gradle @@ -3,7 +3,7 @@ plugins { } group = 'org.jkiss.dbeaver.ext' -version = '2.15.0' +version = '2.16.0' java { sourceCompatibility = JavaVersion.VERSION_21 diff --git a/bindings/java/driver/build.gradle b/bindings/java/driver/build.gradle index 210d2388..b40646ca 100644 --- a/bindings/java/driver/build.gradle +++ b/bindings/java/driver/build.gradle @@ -3,7 +3,7 @@ plugins { } group = 'com.decentdb' -version = '2.15.0' +version = '2.16.0' def repoRoot = file("${rootProject.projectDir}/../..") def nativeLibDirPath = project.findProperty('nativeLibDir') ?: diff --git a/bindings/java/driver/src/main/java/com/decentdb/jdbc/DecentDBDriver.java b/bindings/java/driver/src/main/java/com/decentdb/jdbc/DecentDBDriver.java index c861f139..38f884cc 100644 --- a/bindings/java/driver/src/main/java/com/decentdb/jdbc/DecentDBDriver.java +++ b/bindings/java/driver/src/main/java/com/decentdb/jdbc/DecentDBDriver.java @@ -28,7 +28,7 @@ public final class DecentDBDriver implements Driver { public static final String URL_PREFIX = "jdbc:decentdb:"; - public static final String DRIVER_VERSION = "2.15.0"; + public static final String DRIVER_VERSION = "2.16.0"; public static final int DRIVER_MAJOR_VERSION = 1; public static final int DRIVER_MINOR_VERSION = 8; diff --git a/bindings/node/decentdb/package-lock.json b/bindings/node/decentdb/package-lock.json index 0614dc77..759d6beb 100644 --- a/bindings/node/decentdb/package-lock.json +++ b/bindings/node/decentdb/package-lock.json @@ -1,12 +1,12 @@ { "name": "decentdb-native", - "version": "2.15.0", + "version": "2.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "decentdb-native", - "version": "2.15.0", + "version": "2.16.0", "devDependencies": { "node-gyp": "^12.2.0" } diff --git a/bindings/node/decentdb/package.json b/bindings/node/decentdb/package.json index 7c69154e..0545bb58 100644 --- a/bindings/node/decentdb/package.json +++ b/bindings/node/decentdb/package.json @@ -1,6 +1,6 @@ { "name": "decentdb-native", - "version": "2.15.0", + "version": "2.16.0", "private": true, "description": "DecentDB Node.js native addon (N-API) + thin JS wrapper", "main": "index.js", diff --git a/bindings/node/knex-decentdb/package-lock.json b/bindings/node/knex-decentdb/package-lock.json index e676eb87..eadcbc1f 100644 --- a/bindings/node/knex-decentdb/package-lock.json +++ b/bindings/node/knex-decentdb/package-lock.json @@ -1,12 +1,12 @@ { "name": "knex-decentdb", - "version": "2.15.0", + "version": "2.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "knex-decentdb", - "version": "2.15.0", + "version": "2.16.0", "dependencies": { "decentdb-native": "file:../decentdb" }, @@ -16,7 +16,7 @@ }, "../decentdb": { "name": "decentdb-native", - "version": "2.15.0", + "version": "2.16.0", "devDependencies": { "node-gyp": "^12.2.0" } diff --git a/bindings/node/knex-decentdb/package.json b/bindings/node/knex-decentdb/package.json index 2060cb1d..93a93a88 100644 --- a/bindings/node/knex-decentdb/package.json +++ b/bindings/node/knex-decentdb/package.json @@ -1,6 +1,6 @@ { "name": "knex-decentdb", - "version": "2.15.0", + "version": "2.16.0", "private": true, "description": "Knex client/dialect for DecentDB", "main": "index.js", diff --git a/bindings/python/.tmp/bench_complex_results.json b/bindings/python/.tmp/bench_complex_results.json index c24ae76a..5b8de330 100644 --- a/bindings/python/.tmp/bench_complex_results.json +++ b/bindings/python/.tmp/bench_complex_results.json @@ -11,136 +11,136 @@ "comparisons": { "complex": { "aggregate_p50_ms": { - "decentdb": 0.027542, - "decentdb_vs_sqlite": 4.0905985444824005, + "decentdb": 0.027341, + "decentdb_vs_sqlite": 4.99835466179159, "direction": "lower_is_better", - "sqlite": 0.006733, + "sqlite": 0.00547, "winner": "sqlite" }, "aggregate_p95_ms": { - "decentdb": 0.045155, - "decentdb_vs_sqlite": 0.9162388653288153, + "decentdb": 0.044364, + "decentdb_vs_sqlite": 1.0719048999710061, "direction": "lower_is_better", - "sqlite": 0.049283, - "winner": "decentdb" + "sqlite": 0.041388, + "winner": "sqlite" }, "catalog_insert_s": { - "decentdb": 0.0015566640067845583, - "decentdb_vs_sqlite": 1.0740043507497188, + "decentdb": 0.0022600049996981397, + "decentdb_vs_sqlite": 1.987383663399009, "direction": "lower_is_better", - "sqlite": 0.001449401956051588, + "sqlite": 0.0011371759974281304, "winner": "sqlite" }, "delete_p50_ms": { - "decentdb": 0.030036, - "decentdb_vs_sqlite": 1.1920466722228837, + "decentdb": 0.027331, + "decentdb_vs_sqlite": 1.850816008667976, "direction": "lower_is_better", - "sqlite": 0.025197, + "sqlite": 0.014767, "winner": "sqlite" }, "delete_p95_ms": { - "decentdb": 0.038763, - "decentdb_vs_sqlite": 1.1276837144353289, + "decentdb": 0.0369, + "decentdb_vs_sqlite": 1.6523374529822676, "direction": "lower_is_better", - "sqlite": 0.034374, + "sqlite": 0.022332, "winner": "sqlite" }, "history_p50_ms": { - "decentdb": 0.007043, - "decentdb_vs_sqlite": 1.3415238095238093, + "decentdb": 0.007284, + "decentdb_vs_sqlite": 1.6947417403443463, "direction": "lower_is_better", - "sqlite": 0.00525, + "sqlite": 0.004298, "winner": "sqlite" }, "history_p95_ms": { - "decentdb": 0.008817, - "decentdb_vs_sqlite": 0.7632444598337951, + "decentdb": 0.009968, + "decentdb_vs_sqlite": 1.040718312800167, "direction": "lower_is_better", - "sqlite": 0.011552, - "winner": "decentdb" + "sqlite": 0.009578, + "winner": "sqlite" }, "join_p50_ms": { - "decentdb": 0.049433, - "decentdb_vs_sqlite": 1.2657926407702353, + "decentdb": 0.050615, + "decentdb_vs_sqlite": 1.5846900438321851, "direction": "lower_is_better", - "sqlite": 0.039053, + "sqlite": 0.03194, "winner": "sqlite" }, "join_p95_ms": { - "decentdb": 0.059261, - "decentdb_vs_sqlite": 1.0701953985625023, + "decentdb": 0.069921, + "decentdb_vs_sqlite": 1.76101246694371, "direction": "lower_is_better", - "sqlite": 0.055374, + "sqlite": 0.039705, "winner": "sqlite" }, "orders_insert_rps": { - "decentdb": 438410.81795550423, - "decentdb_vs_sqlite": 1.2324757207935824, + "decentdb": 291240.5514584156, + "decentdb_vs_sqlite": 0.6212594487713966, "direction": "higher_is_better", - "sqlite": 355715.5817018567, - "winner": "decentdb" + "sqlite": 468790.5383722907, + "winner": "sqlite" }, "point_lookup_p50_ms": { - "decentdb": 0.002184, - "decentdb_vs_sqlite": 0.41052631578947374, + "decentdb": 0.002274, + "decentdb_vs_sqlite": 0.5206043956043955, "direction": "lower_is_better", - "sqlite": 0.00532, + "sqlite": 0.004368, "winner": "decentdb" }, "point_lookup_p95_ms": { - "decentdb": 0.002524, - "decentdb_vs_sqlite": 0.39610797237915885, + "decentdb": 0.003737, + "decentdb_vs_sqlite": 0.7986749305407138, "direction": "lower_is_better", - "sqlite": 0.006372, + "sqlite": 0.004679, "winner": "decentdb" }, "range_scan_p50_ms": { - "decentdb": 0.01051, - "decentdb_vs_sqlite": 1.7750380003377808, + "decentdb": 0.010149, + "decentdb_vs_sqlite": 2.084411583487369, "direction": "lower_is_better", - "sqlite": 0.005921, + "sqlite": 0.004869, "winner": "sqlite" }, "range_scan_p95_ms": { - "decentdb": 0.018124, - "decentdb_vs_sqlite": 0.5525272849216512, + "decentdb": 0.0155, + "decentdb_vs_sqlite": 0.539994425863991, "direction": "lower_is_better", - "sqlite": 0.032802, + "sqlite": 0.028704, "winner": "decentdb" }, "report_query_s": { - "decentdb": 4.261010326445103e-05, - "decentdb_vs_sqlite": 0.2392011857521527, + "decentdb": 4.187900049146265e-05, + "decentdb_vs_sqlite": 0.332433702913577, "direction": "lower_is_better", - "sqlite": 0.0001781350001692772, + "sqlite": 0.00012597699969774112, "winner": "decentdb" }, "table_scan_p50_ms": { - "decentdb": 0.002786, - "decentdb_vs_sqlite": 0.6497201492537313, + "decentdb": 0.002855, + "decentdb_vs_sqlite": 0.8049055539892868, "direction": "lower_is_better", - "sqlite": 0.004288, + "sqlite": 0.003547, "winner": "decentdb" }, "table_scan_p95_ms": { - "decentdb": 0.003015, - "decentdb_vs_sqlite": 0.5844155844155844, + "decentdb": 0.003126, + "decentdb_vs_sqlite": 0.8547990155865463, "direction": "lower_is_better", - "sqlite": 0.005159, + "sqlite": 0.003657, "winner": "decentdb" }, "update_p50_ms": { - "decentdb": 0.003938, - "decentdb_vs_sqlite": 0.9183768656716417, + "decentdb": 0.004278, + "decentdb_vs_sqlite": 1.233919815402365, "direction": "lower_is_better", - "sqlite": 0.004288, - "winner": "decentdb" + "sqlite": 0.003467, + "winner": "sqlite" }, "update_p95_ms": { - "decentdb": 0.108864, - "decentdb_vs_sqlite": 16.120835184362505, + "decentdb": 0.113032, + "decentdb_vs_sqlite": 20.36245721491623, "direction": "lower_is_better", - "sqlite": 0.006753, + "sqlite": 0.005551, "winner": "sqlite" } }, @@ -153,24 +153,24 @@ "winner": "tie" }, "movie_bulk_load_rps": { - "decentdb": 101430.16962848562, - "decentdb_vs_sqlite": 0.6640391137069719, + "decentdb": 98972.27147429365, + "decentdb_vs_sqlite": 0.6664961496179039, "direction": "higher_is_better", - "sqlite": 152747.28180130193, + "sqlite": 148496.38896043666, "winner": "sqlite" }, "movie_bulk_load_s": { - "decentdb": 0.4249228819971904, - "decentdb_vs_sqlite": 1.5059353874767103, + "decentdb": 0.43547550599760143, + "decentdb_vs_sqlite": 1.5003837615165379, "direction": "lower_is_better", - "sqlite": 0.28216541395522654, + "sqlite": 0.2902427480003098, "winner": "sqlite" }, "movie_busiest_people_s": { - "decentdb": 0.0003637330373749137, - "decentdb_vs_sqlite": 0.187545097630574, + "decentdb": 0.0003930180027964525, + "decentdb_vs_sqlite": 0.1949138264542111, "direction": "lower_is_better", - "sqlite": 0.0019394430564716458, + "sqlite": 0.0020163680019322783, "winner": "decentdb" }, "movie_busiest_people_s_rows": { @@ -181,17 +181,17 @@ "winner": "tie" }, "movie_checkpoint_after_mutations_s": { - "decentdb": 0.010005995980463922, - "decentdb_vs_sqlite": 0.4754901473469907, + "decentdb": 0.009730276993650477, + "decentdb_vs_sqlite": 0.3843705780401031, "direction": "lower_is_better", - "sqlite": 0.021043540094979107, + "sqlite": 0.02531483300117543, "winner": "decentdb" }, "movie_checkpoint_s": { - "decentdb": 0.03003479994367808, - "decentdb_vs_sqlite": 0.6430164744925787, + "decentdb": 0.04932977199496236, + "decentdb_vs_sqlite": 0.9824332826561517, "direction": "lower_is_better", - "sqlite": 0.04670922306831926, + "sqlite": 0.05021182900236454, "winner": "decentdb" }, "movie_delete_cascade_rows": { @@ -202,10 +202,10 @@ "winner": "tie" }, "movie_delete_cascade_s": { - "decentdb": 0.01813970599323511, - "decentdb_vs_sqlite": 1.6825089261503965, + "decentdb": 0.01761859500402352, + "decentdb_vs_sqlite": 1.516902761227533, "direction": "lower_is_better", - "sqlite": 0.010781343095004559, + "sqlite": 0.011614848001045175, "winner": "sqlite" }, "movie_final_file_size_bytes": { @@ -223,17 +223,17 @@ "winner": "tie" }, "movie_point_reads_s": { - "decentdb": 0.008814257918857038, - "decentdb_vs_sqlite": 1.306574384802287, + "decentdb": 0.009619939999538474, + "decentdb_vs_sqlite": 1.2310548180160552, "direction": "lower_is_better", - "sqlite": 0.006746081984601915, + "sqlite": 0.007814388001861516, "winner": "sqlite" }, "movie_tag_search_s": { - "decentdb": 0.0007657390087842941, - "decentdb_vs_sqlite": 1.9141507778508855, + "decentdb": 0.0007442169953719713, + "decentdb_vs_sqlite": 1.9513996683221306, "direction": "lower_is_better", - "sqlite": 0.00040004111360758543, + "sqlite": 0.0003813759976765141, "winner": "sqlite" }, "movie_tag_search_s_rows": { @@ -244,10 +244,10 @@ "winner": "tie" }, "movie_top_rated_s": { - "decentdb": 0.0005770439747720957, - "decentdb_vs_sqlite": 0.5054632744567573, + "decentdb": 0.0005890270040254109, + "decentdb_vs_sqlite": 0.49967340639234425, "direction": "lower_is_better", - "sqlite": 0.0011416140478104353, + "sqlite": 0.001178824000817258, "winner": "decentdb" }, "movie_top_rated_s_rows": { @@ -265,24 +265,24 @@ "winner": "tie" }, "movie_update_batch_s": { - "decentdb": 0.018583809956908226, - "decentdb_vs_sqlite": 2.2193707507973484, + "decentdb": 0.018743477994576097, + "decentdb_vs_sqlite": 2.4676244294247605, "direction": "lower_is_better", - "sqlite": 0.00837345898617059, + "sqlite": 0.0075957579974783584, "winner": "sqlite" }, "movie_vacuum_s": { - "decentdb": 0.013662113924510777, - "decentdb_vs_sqlite": 0.2274789312877222, + "decentdb": 0.011847595000290312, + "decentdb_vs_sqlite": 0.21401804711582534, "direction": "lower_is_better", - "sqlite": 0.060058810049667954, + "sqlite": 0.05535792499722447, "winner": "decentdb" }, "movie_watchlist_s": { - "decentdb": 0.00015029299538582563, - "decentdb_vs_sqlite": 0.7034509777425176, + "decentdb": 0.00018203200306743383, + "decentdb_vs_sqlite": 0.737589806672207, "direction": "lower_is_better", - "sqlite": 0.00021365098655223846, + "sqlite": 0.0002467930025886744, "winner": "decentdb" }, "movie_watchlist_s_rows": { @@ -620,7 +620,7 @@ "status": "skipped" } }, - "generated_at": "2026-06-29T22:35:27.265843+00:00", + "generated_at": "2026-07-01T04:03:43.198977+00:00", "python": { "executable": "/usr/bin/python", "version": "3.14.6" @@ -628,46 +628,46 @@ "results": { "complex": { "decentdb": { - "aggregate_p50_ms": 0.027542, - "aggregate_p95_ms": 0.045155, - "catalog_insert_s": 0.0015566640067845583, - "delete_p50_ms": 0.030036, - "delete_p95_ms": 0.038763, - "history_p50_ms": 0.007043, - "history_p95_ms": 0.008817, - "join_p50_ms": 0.049433, - "join_p95_ms": 0.059261, - "orders_insert_rps": 438410.81795550423, - "point_lookup_p50_ms": 0.002184, - "point_lookup_p95_ms": 0.002524, - "range_scan_p50_ms": 0.01051, - "range_scan_p95_ms": 0.018124, - "report_query_s": 4.261010326445103e-05, - "table_scan_p50_ms": 0.002786, - "table_scan_p95_ms": 0.003015, - "update_p50_ms": 0.003938, - "update_p95_ms": 0.108864 + "aggregate_p50_ms": 0.027341, + "aggregate_p95_ms": 0.044364, + "catalog_insert_s": 0.0022600049996981397, + "delete_p50_ms": 0.027331, + "delete_p95_ms": 0.0369, + "history_p50_ms": 0.007284, + "history_p95_ms": 0.009968, + "join_p50_ms": 0.050615, + "join_p95_ms": 0.069921, + "orders_insert_rps": 291240.5514584156, + "point_lookup_p50_ms": 0.002274, + "point_lookup_p95_ms": 0.003737, + "range_scan_p50_ms": 0.010149, + "range_scan_p95_ms": 0.0155, + "report_query_s": 4.187900049146265e-05, + "table_scan_p50_ms": 0.002855, + "table_scan_p95_ms": 0.003126, + "update_p50_ms": 0.004278, + "update_p95_ms": 0.113032 }, "sqlite": { - "aggregate_p50_ms": 0.006733, - "aggregate_p95_ms": 0.049283, - "catalog_insert_s": 0.001449401956051588, - "delete_p50_ms": 0.025197, - "delete_p95_ms": 0.034374, - "history_p50_ms": 0.00525, - "history_p95_ms": 0.011552, - "join_p50_ms": 0.039053, - "join_p95_ms": 0.055374, - "orders_insert_rps": 355715.5817018567, - "point_lookup_p50_ms": 0.00532, - "point_lookup_p95_ms": 0.006372, - "range_scan_p50_ms": 0.005921, - "range_scan_p95_ms": 0.032802, - "report_query_s": 0.0001781350001692772, - "table_scan_p50_ms": 0.004288, - "table_scan_p95_ms": 0.005159, - "update_p50_ms": 0.004288, - "update_p95_ms": 0.006753 + "aggregate_p50_ms": 0.00547, + "aggregate_p95_ms": 0.041388, + "catalog_insert_s": 0.0011371759974281304, + "delete_p50_ms": 0.014767, + "delete_p95_ms": 0.022332, + "history_p50_ms": 0.004298, + "history_p95_ms": 0.009578, + "join_p50_ms": 0.03194, + "join_p95_ms": 0.039705, + "orders_insert_rps": 468790.5383722907, + "point_lookup_p50_ms": 0.004368, + "point_lookup_p95_ms": 0.004679, + "range_scan_p50_ms": 0.004869, + "range_scan_p95_ms": 0.028704, + "report_query_s": 0.00012597699969774112, + "table_scan_p50_ms": 0.003547, + "table_scan_p95_ms": 0.003657, + "update_p50_ms": 0.003467, + "update_p95_ms": 0.005551 } }, "movie": { @@ -789,25 +789,25 @@ } }, "busiest_people_rows": 20, - "movie_bulk_load_rps": 101430.16962848562, - "movie_bulk_load_s": 0.4249228819971904, - "movie_busiest_people_s": 0.0003637330373749137, + "movie_bulk_load_rps": 98972.27147429365, + "movie_bulk_load_s": 0.43547550599760143, + "movie_busiest_people_s": 0.0003930180027964525, "movie_busiest_people_s_rows": 20, - "movie_checkpoint_after_mutations_s": 0.010005995980463922, - "movie_checkpoint_s": 0.03003479994367808, + "movie_checkpoint_after_mutations_s": 0.009730276993650477, + "movie_checkpoint_s": 0.04932977199496236, "movie_delete_cascade_rows": 10, - "movie_delete_cascade_s": 0.01813970599323511, + "movie_delete_cascade_s": 0.01761859500402352, "movie_final_file_size_bytes": 7630848, "movie_point_reads_rows": 1000, - "movie_point_reads_s": 0.008814257918857038, - "movie_tag_search_s": 0.0007657390087842941, + "movie_point_reads_s": 0.009619939999538474, + "movie_tag_search_s": 0.0007442169953719713, "movie_tag_search_s_rows": 50, - "movie_top_rated_s": 0.0005770439747720957, + "movie_top_rated_s": 0.0005890270040254109, "movie_top_rated_s_rows": 0, "movie_update_batch_rows": 1000, - "movie_update_batch_s": 0.018583809956908226, - "movie_vacuum_s": 0.013662113924510777, - "movie_watchlist_s": 0.00015029299538582563, + "movie_update_batch_s": 0.018743477994576097, + "movie_vacuum_s": 0.011847595000290312, + "movie_watchlist_s": 0.00018203200306743383, "movie_watchlist_s_rows": 2, "movies_after": 1990, "movies_before": 2000, @@ -943,25 +943,25 @@ } }, "busiest_people_rows": 20, - "movie_bulk_load_rps": 152747.28180130193, - "movie_bulk_load_s": 0.28216541395522654, - "movie_busiest_people_s": 0.0019394430564716458, + "movie_bulk_load_rps": 148496.38896043666, + "movie_bulk_load_s": 0.2902427480003098, + "movie_busiest_people_s": 0.0020163680019322783, "movie_busiest_people_s_rows": 20, - "movie_checkpoint_after_mutations_s": 0.021043540094979107, - "movie_checkpoint_s": 0.04670922306831926, + "movie_checkpoint_after_mutations_s": 0.02531483300117543, + "movie_checkpoint_s": 0.05021182900236454, "movie_delete_cascade_rows": 10, - "movie_delete_cascade_s": 0.010781343095004559, + "movie_delete_cascade_s": 0.011614848001045175, "movie_final_file_size_bytes": 9400320, "movie_point_reads_rows": 1000, - "movie_point_reads_s": 0.006746081984601915, - "movie_tag_search_s": 0.00040004111360758543, + "movie_point_reads_s": 0.007814388001861516, + "movie_tag_search_s": 0.0003813759976765141, "movie_tag_search_s_rows": 50, - "movie_top_rated_s": 0.0011416140478104353, + "movie_top_rated_s": 0.001178824000817258, "movie_top_rated_s_rows": 0, "movie_update_batch_rows": 1000, - "movie_update_batch_s": 0.00837345898617059, - "movie_vacuum_s": 0.060058810049667954, - "movie_watchlist_s": 0.00021365098655223846, + "movie_update_batch_s": 0.0075957579974783584, + "movie_vacuum_s": 0.05535792499722447, + "movie_watchlist_s": 0.0002467930025886744, "movie_watchlist_s_rows": 2, "movies_after": 1990, "movies_before": 2000, diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml index dace11ab..92bbbaa4 100644 --- a/bindings/python/pyproject.toml +++ b/bindings/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "decentdb" -version = "2.15.0" +version = "2.16.0" description = "Python DB-API 2.0 driver and SQLAlchemy dialect for DecentDB" readme = "README.md" authors = [ diff --git a/crates/decentdb/src/c_api.rs b/crates/decentdb/src/c_api.rs index 7b271f2a..f1b31d29 100644 --- a/crates/decentdb/src/c_api.rs +++ b/crates/decentdb/src/c_api.rs @@ -3259,10 +3259,12 @@ pub extern "C" fn ddb_stmt_execute_batch_i64_text_f64( /// Execute a batch of rows using a type signature string. /// /// `signature` is a NUL-terminated ASCII string where each character describes -/// one column per row: `'i'` = INT64, `'t'` = TEXT, `'f'` = FLOAT64. +/// one column per row: `'i'` = INT64, `'b'` = BOOLEAN, `'t'` = TEXT, +/// `'f'` = FLOAT64. /// /// The caller provides flat, row-major arrays for each type: -/// - `values_i64`: all INT64 values, packed in row order +/// - `values_i64`: all INT64 and BOOLEAN values, packed in row order. BOOLEAN +/// uses `0` for false and non-zero for true. /// - `values_f64`: all FLOAT64 values, packed in row order /// - `values_text_ptrs` / `values_text_lens`: text pointer/length pairs, row order /// @@ -3299,7 +3301,7 @@ pub extern "C" fn ddb_stmt_execute_batch_typed( let mut text_per_row: usize = 0; for &ch in sig { match ch { - b'i' => i64_per_row += 1, + b'i' | b'b' => i64_per_row += 1, b'f' => f64_per_row += 1, b't' => text_per_row += 1, other => { @@ -3354,6 +3356,11 @@ pub extern "C" fn ddb_stmt_execute_batch_typed( params[col] = Value::Int64(i64_vals[row_idx * i64_per_row + i_off]); i_off += 1; } + b'b' => { + params[col] = + Value::Bool(i64_vals[row_idx * i64_per_row + i_off] != 0); + i_off += 1; + } b'f' => { params[col] = Value::Float64(f64_vals[row_idx * f64_per_row + f_off]); f_off += 1; diff --git a/crates/decentdb/src/db/tests.rs b/crates/decentdb/src/db/tests.rs index 5f610f85..ae5c3c64 100644 --- a/crates/decentdb/src/db/tests.rs +++ b/crates/decentdb/src/db/tests.rs @@ -14015,6 +14015,7 @@ fn dummy_prepared_insert(table_name: &str) -> PreparedSimpleInsert { primary_auto_row_id_column_index: None, value_sources: vec![PreparedInsertValueSource::Null], required_columns: Vec::new(), + generated_columns: Vec::new(), foreign_keys: Vec::new(), unique_indexes: Vec::new(), insert_indexes: Vec::new(), diff --git a/crates/decentdb/src/exec/dml.rs b/crates/decentdb/src/exec/dml.rs index bd58683a..f6903eaf 100644 --- a/crates/decentdb/src/exec/dml.rs +++ b/crates/decentdb/src/exec/dml.rs @@ -51,6 +51,7 @@ pub(crate) struct PreparedBtreeIndex { pub(crate) covering_payload_column_indexes: Vec, pub(crate) nullable: bool, pub(crate) unique: bool, + pub(crate) predicate_expr: Option, } #[derive(Clone, Debug)] @@ -66,6 +67,12 @@ pub(crate) struct PreparedRequiredColumn { pub(crate) name: String, } +#[derive(Clone, Debug)] +pub(crate) struct PreparedGeneratedColumn { + pub(crate) index: usize, + pub(crate) expr: Expr, +} + #[derive(Clone, Debug)] pub(crate) struct PreparedForeignKey { pub(crate) child_column_indexes: Vec, @@ -93,6 +100,7 @@ pub(crate) struct PreparedSimpleInsert { pub(crate) primary_auto_row_id_column_index: Option, pub(crate) value_sources: Vec, pub(crate) required_columns: Vec, + pub(crate) generated_columns: Vec, pub(crate) foreign_keys: Vec, pub(crate) unique_indexes: Vec, pub(crate) insert_indexes: Vec, @@ -254,6 +262,17 @@ impl PreparedSimpleDelete { ); } } + for children in self.delete_children_by_table.values() { + for child in children { + if let Some(index_name) = child.child_index_name.as_deref() { + push_unique_child_index_hydration_target( + &mut targets, + child.child_table.name.as_str(), + index_name, + ); + } + } + } targets } } @@ -292,6 +311,12 @@ struct PreparedIntArithmeticUpdate { delta_source: PreparedSimpleValueSource, } +#[derive(Clone, Debug)] +struct PreparedBoolUpdate { + column_index: usize, + value_source: PreparedSimpleValueSource, +} + impl EngineRuntime { fn record_sync_update_for_row( &mut self, @@ -619,17 +644,6 @@ impl EngineRuntime { { return false; } - if self - .table_schema(&statement.table_name) - .is_some_and(|table| { - table - .columns - .iter() - .any(|column| column.generated_sql.is_some()) - }) - { - return false; - } if !matches!(&statement.source, InsertSource::Values(rows) if rows.len() == 1) { return false; } @@ -725,6 +739,7 @@ impl EngineRuntime { table .columns .iter() + .filter(|column| column.generated_sql.is_none()) .map(|column| column.name.clone()) .collect::>() } else { @@ -746,6 +761,12 @@ impl EngineRuntime { .iter() .position(|column| identifiers_equal(&column.name, column_name)) .ok_or_else(|| DbError::sql(format!("unknown column {}", column_name)))?; + if table.columns[column_index].generated_sql.is_some() { + return Err(DbError::sql(format!( + "cannot INSERT into generated column {}.{}", + table.name, column_name + ))); + } if assigned[column_index].is_some() { return Err(DbError::sql(format!( "column {} was assigned more than once in INSERT", @@ -760,6 +781,10 @@ impl EngineRuntime { let mut value_sources = Vec::with_capacity(table.columns.len()); for (index, column) in table.columns.iter().enumerate() { + if column.generated_sql.is_some() { + value_sources.push(PreparedInsertValueSource::Null); + continue; + } if let Some(source) = assigned[index].take() { value_sources.push(source); continue; @@ -801,6 +826,21 @@ impl EngineRuntime { }) }) .collect::>(); + let generated_columns = table + .columns + .iter() + .enumerate() + .filter_map(|(index, column)| { + if !column.generated_stored { + return None; + } + let generated_sql = column.generated_sql.as_ref()?; + Some( + parse_expression_sql(generated_sql) + .map(|expr| PreparedGeneratedColumn { index, expr }), + ) + }) + .collect::>>()?; let direct_positional_param_count = if value_sources .iter() @@ -825,9 +865,13 @@ impl EngineRuntime { row_source_dependency_tables.push(foreign_key.referenced_table.clone()); } } - let mut use_generic_validation = - table.columns.iter().any(|column| !column.checks.is_empty()) - || !table.checks.is_empty(); + let has_virtual_generated_columns = table + .columns + .iter() + .any(|column| column.generated_sql.is_some() && !column.generated_stored); + let mut use_generic_validation = has_virtual_generated_columns + || table.columns.iter().any(|column| !column.checks.is_empty()) + || !table.checks.is_empty(); let mut foreign_keys = Vec::new(); for foreign_key in &table.foreign_keys { let Some(prepared_foreign_key) = prepare_foreign_key(self, table, foreign_key)? else { @@ -883,6 +927,7 @@ impl EngineRuntime { primary_auto_row_id_column_index, value_sources, required_columns, + generated_columns, foreign_keys, unique_indexes, insert_indexes, @@ -1787,11 +1832,12 @@ impl EngineRuntime { page_size: u32, ) -> Result { let mut next_row_id = prepared_next_row_id(self, prepared)?; - let candidate = if prepared.direct_positional_param_count == Some(params.len()) { + let mut candidate = if prepared.direct_positional_param_count == Some(params.len()) { materialize_direct_positional_insert_candidate(prepared, params, &mut next_row_id)? } else { materialize_prepared_insert_candidate(self, prepared, params, &mut next_row_id)? }; + apply_prepared_generated_columns(self, prepared, &mut candidate, params)?; let sync_schema = if self.mutation_capture_active() { self.table_schema(prepared.table_name.as_str()) .filter(|schema| !schema.temporary) @@ -1834,11 +1880,12 @@ impl EngineRuntime { page_size: u32, ) -> Result { let mut next_row_id = prepared_next_row_id(self, prepared)?; - let candidate = if prepared.direct_positional_param_count == Some(params.len()) { + let mut candidate = if prepared.direct_positional_param_count == Some(params.len()) { materialize_direct_positional_insert_candidate(prepared, params, &mut next_row_id)? } else { materialize_prepared_insert_candidate(self, prepared, params, &mut next_row_id)? }; + apply_prepared_generated_columns(self, prepared, &mut candidate, params)?; let sync_schema = if self.mutation_capture_active() { self.table_schema(prepared.table_name.as_str()) .filter(|schema| !schema.temporary) @@ -1950,6 +1997,7 @@ impl EngineRuntime { candidate.push(cast_prepared_owned_value(value, column.column_type)?); } } + apply_prepared_generated_columns(self, prepared, candidate, params)?; self.apply_prepared_simple_insert_candidate( prepared, @@ -2014,6 +2062,7 @@ impl EngineRuntime { candidate.push(cast_prepared_owned_value(value, column.column_type)?); } } + apply_prepared_generated_columns(self, prepared, candidate, params)?; let (affected, _stored_row, new_next_row_id) = self .apply_prepared_simple_insert_candidate_with_next_row_id_mode( @@ -2306,6 +2355,44 @@ impl EngineRuntime { ) } + fn apply_delete_row_ids_to_table_row_source( + &mut self, + table_name: &str, + row_ids: &BTreeSet, + ) -> Result<()> { + if row_ids.is_empty() { + return Ok(()); + } + if self.temp_table_schema(table_name).is_some() { + let table_data = self.temp_table_data_mut(table_name).ok_or_else(|| { + DbError::internal(format!("table data for {table_name} is missing")) + })?; + table_data.mark_existing_row_set_deleted(row_ids); + return Ok(()); + } + if matches!( + self.table_row_source(table_name), + Some(TableRowSource::Resident(_)) + ) { + let table_data = self.table_data_mut(table_name).ok_or_else(|| { + DbError::internal(format!("table data for {table_name} is missing")) + })?; + table_data.mark_existing_row_set_deleted(row_ids); + return Ok(()); + } + let Some(TableRowSource::Paged(manifest)) = self.table_row_source(table_name) else { + return Err(DbError::internal(format!( + "table row source for {table_name} is missing" + ))); + }; + let updated_manifest = + super::apply_paged_row_deletions_to_manifest(manifest.as_ref(), row_ids)?; + self.replace_table_row_source( + table_name, + TableRowSource::Paged(Arc::new(updated_manifest)), + ) + } + fn apply_row_changes_to_resident_table_data( table_data: &mut super::TableData, row_changes: &BTreeMap>>, @@ -2515,6 +2602,20 @@ impl EngineRuntime { let mut returning_rows = Vec::new(); let mut row_changes = BTreeMap::new(); let mut stale_indexes: Vec = Vec::new(); + let index_predicates = indexes_to_update + .iter() + .map(super::prepare_index_predicate_expr) + .collect::>>()?; + let simple_resolved_assignments = resolve_simple_update_assignments( + table, + &statement.assignments, + assignment_columns, + params, + )?; + let has_generated_columns = table + .columns + .iter() + .any(|column| column.generated_sql.is_some()); for &row_id in matching_row_ids { let current_row = manifest @@ -2524,23 +2625,38 @@ impl EngineRuntime { values: row.values().to_vec(), }) .ok_or_else(|| DbError::internal(format!("row {row_id} vanished during UPDATE")))?; - let current_eval_values = - materialize_row_for_generated(self, table, ¤t_row.values)?.into_owned(); let mut next_values = current_row.values.clone(); - let dataset = table_row_dataset(table, ¤t_eval_values, &table.name); - for (assignment, column_index) in statement.assignments.iter().zip(assignment_columns) { - let value = self.eval_expr( - &assignment.expr, - &dataset, - ¤t_eval_values, - params, - &std::collections::BTreeMap::new(), - None, - )?; - next_values[*column_index] = - super::constraints::coerce_column_value(&table.columns[*column_index], value)?; + if let Some(simple_resolved_assignments) = simple_resolved_assignments.as_ref() { + for (column_index, value) in simple_resolved_assignments { + next_values[*column_index] = value.clone(); + } + } else { + let current_eval_values = if has_generated_columns { + materialize_row_for_generated(self, table, ¤t_row.values)? + } else { + Cow::Borrowed(current_row.values.as_slice()) + }; + let dataset = table_row_dataset(table, current_eval_values.as_ref(), &table.name); + for (assignment, column_index) in + statement.assignments.iter().zip(assignment_columns) + { + let value = self.eval_expr( + &assignment.expr, + &dataset, + current_eval_values.as_ref(), + params, + &std::collections::BTreeMap::new(), + None, + )?; + next_values[*column_index] = super::constraints::coerce_column_value( + &table.columns[*column_index], + value, + )?; + } + } + if has_generated_columns { + apply_generated_columns(self, table, &mut next_values, params)?; } - apply_generated_columns(self, table, &mut next_values, params)?; if next_values == current_row.values { affected_rows += 1; if !statement.returning.is_empty() { @@ -2568,7 +2684,7 @@ impl EngineRuntime { page_size, )?; } - for index in indexes_to_update { + for (index, predicate_expr) in indexes_to_update.iter().zip(&index_predicates) { if !index.fresh { if !stale_indexes.contains(&index.name) { stale_indexes.push(index.name.clone()); @@ -2582,6 +2698,7 @@ impl EngineRuntime { row_id, ¤t_row.values, &next_values, + predicate_expr.as_ref(), )? && !stale_indexes.contains(&index.name) { stale_indexes.push(index.name.clone()); @@ -2878,6 +2995,146 @@ impl EngineRuntime { row_id, ¤t_row.values, &next_values, + None, + )? && !stale_indexes.contains(&index.name) + { + stale_indexes.push(index.name.clone()); + } + } + + self.record_sync_update_for_row(table, &next_values); + row_changes.insert(row_id, Some(next_values.clone())); + if !returning.is_empty() { + returning_rows.push(StoredRow { + row_id, + values: next_values, + }); + } + changed_rows += 1; + affected_rows += 1; + } + + if changed_rows > 0 { + let updated_manifest = + super::apply_paged_row_changes_to_manifest(manifest.as_ref(), &row_changes)?; + self.replace_table_row_source( + &table.name, + TableRowSource::Paged(Arc::new(updated_manifest)), + )?; + for (row_id, next_values) in &row_changes { + if let Some(values) = next_values { + self.mark_table_row_dirty(&table.name, 0, *row_id, values); + } + } + if !stale_indexes.is_empty() { + self.mark_named_indexes_stale(&stale_indexes); + } + } + + self.execute_after_triggers( + &table.name, + TriggerEvent::Update, + affected_rows as usize, + page_size, + )?; + if returning.is_empty() { + Ok(Some(QueryResult::with_affected_rows(affected_rows))) + } else { + Ok(Some(self.render_returning( + &table.name, + &returning_rows, + returning, + params, + )?)) + } + } + + #[allow(clippy::too_many_arguments)] + fn try_execute_paged_bool_update( + &mut self, + table: &crate::catalog::TableSchema, + matching_row_ids: &[i64], + prepared_update: &PreparedBoolUpdate, + indexes_to_update: &[crate::catalog::IndexSchema], + returning: &[crate::sql::ast::SelectItem], + params: &[Value], + page_size: u32, + ) -> Result> { + let Some(TableRowSource::Paged(manifest)) = self.table_row_source(&table.name).cloned() + else { + return Ok(None); + }; + + let resolved_value = resolve_prepared_simple_value(&prepared_update.value_source, params)?; + let resolved_value = super::constraints::coerce_column_value( + &table.columns[prepared_update.column_index], + resolved_value, + )?; + + let mut prepared_indexes = Vec::new(); + let mut stale_indexes: Vec = Vec::new(); + for index in indexes_to_update { + if !index.fresh || index.kind != IndexKind::Btree { + if !stale_indexes.contains(&index.name) { + stale_indexes.push(index.name.clone()); + } + continue; + } + match prepare_btree_insert_index(self, table, index)? { + Some(prepared_index) => prepared_indexes.push(prepared_index), + None => { + if !stale_indexes.contains(&index.name) { + stale_indexes.push(index.name.clone()); + } + } + } + } + + let mut affected_rows = 0_u64; + let mut changed_rows = 0_u64; + let mut row_changes = BTreeMap::new(); + let mut returning_rows = Vec::new(); + + for &row_id in matching_row_ids { + let current_row = manifest + .row_by_id(row_id)? + .map(|row| StoredRow { + row_id, + values: row.values().to_vec(), + }) + .ok_or_else(|| DbError::internal(format!("row {row_id} vanished during UPDATE")))?; + + let Some(current_value) = current_row.values.get(prepared_update.column_index) else { + return Err(DbError::internal(format!( + "column index {} is invalid for {}", + prepared_update.column_index, table.name + ))); + }; + + if *current_value == resolved_value { + affected_rows += 1; + if !returning.is_empty() { + returning_rows.push(current_row); + } + continue; + } + + let mut next_values = current_row.values.clone(); + next_values[prepared_update.column_index] = resolved_value.clone(); + validate_assigned_not_null_columns( + table, + std::slice::from_ref(&prepared_update.column_index), + &next_values, + &table.name, + )?; + for index in &prepared_indexes { + if !apply_prepared_btree_index_update_for_row_change( + self, + &table.name, + index, + row_id, + ¤t_row.values, + &next_values, )? && !stale_indexes.contains(&index.name) { stale_indexes.push(index.name.clone()); @@ -3042,6 +3299,7 @@ impl EngineRuntime { row_id, old_values, &next_values, + None, )? && !stale_indexes.contains(&index.name) { stale_indexes.push(index.name.clone()); @@ -3136,43 +3394,258 @@ impl EngineRuntime { } } - pub(super) fn execute_update( + #[allow(clippy::too_many_arguments)] + fn try_execute_resident_bool_update( &mut self, - statement: &UpdateStatement, + table: &crate::catalog::TableSchema, + matching_row_ids: &[i64], + prepared_update: &PreparedBoolUpdate, + indexes_to_update: &[crate::catalog::IndexSchema], + returning: &[crate::sql::ast::SelectItem], params: &[Value], page_size: u32, - ) -> Result { - if self - .visible_view(&statement.table_name, super::NameResolutionScope::Session) - .is_some() - { - if !statement.returning.is_empty() { - return Err(DbError::sql( - "UPDATE ... RETURNING is not supported for view INSTEAD OF triggers", - )); - } - let affected = view_match_count( - self, - &statement.table_name, - statement.filter.as_ref(), - params, - )?; - let affected = self.execute_instead_of_triggers( - &statement.table_name, - TriggerEvent::Update, - affected, - page_size, - )?; - return Ok(QueryResult::with_affected_rows(affected)); - } + ) -> Result> { + let Some(TableRowSource::Resident(_)) = self.table_row_source(&table.name) else { + return Ok(None); + }; + + let resolved_value = resolve_prepared_simple_value(&prepared_update.value_source, params)?; + let resolved_value = super::constraints::coerce_column_value( + &table.columns[prepared_update.column_index], + resolved_value, + )?; + + let mut prepared_indexes = Vec::new(); + let mut stale_indexes: Vec = Vec::new(); + for index in indexes_to_update { + if !index.fresh || index.kind != IndexKind::Btree { + if !stale_indexes.contains(&index.name) { + stale_indexes.push(index.name.clone()); + } + continue; + } + match prepare_btree_insert_index(self, table, index)? { + Some(prepared_index) => prepared_indexes.push(prepared_index), + None => { + if !stale_indexes.contains(&index.name) { + stale_indexes.push(index.name.clone()); + } + } + } + } + + let mut affected_rows = 0_u64; + let mut changed_rows = 0_u64; + let mut returning_rows = Vec::new(); + + for &row_id in matching_row_ids { + let (row_index, current_value, old_values) = { + let Some(table_data) = self.table_data(&table.name) else { + return Err(DbError::internal(format!( + "table data for {} is missing", + table.name + ))); + }; + let row_index = table_data.row_index_by_id(row_id).ok_or_else(|| { + DbError::internal(format!("row {row_id} vanished during UPDATE")) + })?; + let stored_row = &table_data.rows[row_index]; + let current_value = stored_row.values.get(prepared_update.column_index).cloned(); + let old_values = if prepared_indexes.is_empty() { + None + } else { + Some(stored_row.values.clone()) + }; + (row_index, current_value, old_values) + }; + + let Some(current_value) = current_value else { + return Err(DbError::internal(format!( + "column index {} is invalid for {}", + prepared_update.column_index, table.name + ))); + }; + + if current_value == resolved_value { + affected_rows += 1; + if !returning.is_empty() { + let values = if let Some(values) = old_values.as_ref() { + values.clone() + } else { + let Some(table_data) = self.table_data(&table.name) else { + return Err(DbError::internal(format!( + "table data for {} is missing", + table.name + ))); + }; + table_data.rows[row_index].values.clone() + }; + returning_rows.push(StoredRow { row_id, values }); + } + continue; + } + + if let Some(old_values) = old_values.as_ref() { + let mut next_values = old_values.clone(); + next_values[prepared_update.column_index] = resolved_value.clone(); + validate_assigned_not_null_columns( + table, + std::slice::from_ref(&prepared_update.column_index), + &next_values, + &table.name, + )?; + for index in &prepared_indexes { + if !apply_prepared_btree_index_update_for_row_change( + self, + &table.name, + index, + row_id, + old_values, + &next_values, + )? && !stale_indexes.contains(&index.name) + { + stale_indexes.push(index.name.clone()); + } + } + { + let Some(table_data) = self.table_data_mut(&table.name) else { + return Err(DbError::internal(format!( + "table data for {} is missing", + table.name + ))); + }; + table_data + .replace_row_values(row_index, next_values.clone()) + .ok_or_else(|| { + DbError::internal(format!("row {row_id} vanished during UPDATE")) + })?; + } + self.mark_table_row_dirty_with_original_values( + &table.name, + row_index, + row_id, + old_values, + &next_values, + ); + self.record_sync_update_for_row(table, &next_values); + if !returning.is_empty() { + returning_rows.push(StoredRow { + row_id, + values: next_values, + }); + } + } else { + let (next_values, old_values) = { + let Some(table_data) = self.table_data_mut(&table.name) else { + return Err(DbError::internal(format!( + "table data for {} is missing", + table.name + ))); + }; + let old_values = table_data.rows[row_index].values.clone(); + table_data + .replace_value( + row_index, + prepared_update.column_index, + resolved_value.clone(), + ) + .ok_or_else(|| { + DbError::internal(format!("row {row_id} vanished during UPDATE")) + })?; + let next_values = table_data.rows[row_index].values.clone(); + (next_values, old_values) + }; + validate_assigned_not_null_columns( + table, + std::slice::from_ref(&prepared_update.column_index), + &next_values, + &table.name, + )?; + if !returning.is_empty() { + returning_rows.push(StoredRow { + row_id, + values: next_values.clone(), + }); + } + self.mark_table_row_dirty_with_original_values( + &table.name, + row_index, + row_id, + &old_values, + &next_values, + ); + self.record_sync_update_for_row(table, &next_values); + } + + changed_rows += 1; + affected_rows += 1; + } + + if changed_rows > 0 && !stale_indexes.is_empty() { + self.mark_named_indexes_stale(&stale_indexes); + } + + self.execute_after_triggers( + &table.name, + TriggerEvent::Update, + affected_rows as usize, + page_size, + )?; + if returning.is_empty() { + Ok(Some(QueryResult::with_affected_rows(affected_rows))) + } else { + Ok(Some(self.render_returning( + &table.name, + &returning_rows, + returning, + params, + )?)) + } + } + + pub(super) fn execute_update( + &mut self, + statement: &UpdateStatement, + params: &[Value], + page_size: u32, + ) -> Result { + if self + .visible_view(&statement.table_name, super::NameResolutionScope::Session) + .is_some() + { + if !statement.returning.is_empty() { + return Err(DbError::sql( + "UPDATE ... RETURNING is not supported for view INSTEAD OF triggers", + )); + } + let affected = view_match_count( + self, + &statement.table_name, + statement.filter.as_ref(), + params, + )?; + let affected = self.execute_instead_of_triggers( + &statement.table_name, + TriggerEvent::Update, + affected, + page_size, + )?; + return Ok(QueryResult::with_affected_rows(affected)); + } let table_name = statement.table_name.clone(); let table = self .table_schema(&table_name) .cloned() .ok_or_else(|| DbError::sql(format!("unknown table {}", table_name)))?; - let matching_row_ids = - matching_row_ids(self, &table_name, &table, statement.filter.as_ref(), params)?; + let matching_row_ids = matching_row_ids( + self, + &table_name, + &table_name, + &table, + statement.filter.as_ref(), + params, + )?; let table_indexes = self .catalog .indexes @@ -3231,6 +3704,21 @@ impl EngineRuntime { && !has_referencing_tables && !updates_foreign_key_columns { + if let Some(prepared_update) = + compile_prepared_bool_update(statement, &table, &assignment_columns) + { + if let Some(result) = self.try_execute_paged_bool_update( + &table, + &matching_row_ids, + &prepared_update, + &indexes_to_update, + &statement.returning, + params, + page_size, + )? { + return Ok(result); + } + } if let Some(prepared_update) = compile_int_arithmetic_update(statement, &table, &assignment_columns) { @@ -3266,6 +3754,21 @@ impl EngineRuntime { } } if assignment_only_validation && !has_referencing_tables && !updates_foreign_key_columns { + if let Some(prepared_update) = + compile_prepared_bool_update(statement, &table, &assignment_columns) + { + if let Some(result) = self.try_execute_resident_bool_update( + &table, + &matching_row_ids, + &prepared_update, + &indexes_to_update, + &statement.returning, + params, + page_size, + )? { + return Ok(result); + } + } if let Some(prepared_update) = compile_int_arithmetic_update(statement, &table, &assignment_columns) { @@ -3423,6 +3926,7 @@ impl EngineRuntime { single_row_id, ¤t_row.values, &next_values, + None, )? && !stale_indexes.contains(&index.name) { stale_indexes.push(index.name.clone()); @@ -3508,6 +4012,16 @@ impl EngineRuntime { let mut changed_rows = 0_u64; let mut returning_rows = Vec::new(); let mut stale_indexes: Vec = Vec::new(); + let simple_resolved_assignments = resolve_simple_update_assignments( + &table, + &statement.assignments, + &assignment_columns, + params, + )?; + let has_generated_columns = table + .columns + .iter() + .any(|column| column.generated_sql.is_some()); for row_id in matching_row_ids { let (row_index, current_row) = { let table_data = self.table_data(&table_name).ok_or_else(|| { @@ -3518,33 +4032,47 @@ impl EngineRuntime { })?; (row_index, table_data.rows[row_index].clone()) }; - let current_eval_values = - materialize_row_for_generated(self, &table, ¤t_row.values)?.into_owned(); let mut next_values = current_row.values.clone(); - let dataset = table_row_dataset(&table, ¤t_eval_values, &table_name); - for (assignment, column_index) in statement.assignments.iter().zip(&assignment_columns) - { - let value = self.eval_expr( - &assignment.expr, - &dataset, - ¤t_eval_values, - params, - &std::collections::BTreeMap::new(), - None, - )?; - next_values[*column_index] = - super::constraints::coerce_column_value(&table.columns[*column_index], value)?; - } - apply_generated_columns(self, &table, &mut next_values, params)?; - if next_values == current_row.values { - affected_rows += 1; - if !statement.returning.is_empty() { - returning_rows.push(current_row); + if let Some(simple_resolved_assignments) = simple_resolved_assignments.as_ref() { + for (column_index, value) in simple_resolved_assignments { + next_values[*column_index] = value.clone(); } - continue; - } - if has_referencing_tables { - self.apply_parent_update_actions( + } else { + let current_eval_values = if has_generated_columns { + materialize_row_for_generated(self, &table, ¤t_row.values)? + } else { + Cow::Borrowed(current_row.values.as_slice()) + }; + let dataset = table_row_dataset(&table, current_eval_values.as_ref(), &table_name); + for (assignment, column_index) in + statement.assignments.iter().zip(&assignment_columns) + { + let value = self.eval_expr( + &assignment.expr, + &dataset, + current_eval_values.as_ref(), + params, + &std::collections::BTreeMap::new(), + None, + )?; + next_values[*column_index] = super::constraints::coerce_column_value( + &table.columns[*column_index], + value, + )?; + } + } + if has_generated_columns { + apply_generated_columns(self, &table, &mut next_values, params)?; + } + if next_values == current_row.values { + affected_rows += 1; + if !statement.returning.is_empty() { + returning_rows.push(current_row); + } + continue; + } + if has_referencing_tables { + self.apply_parent_update_actions( &table_name, &table, ¤t_row.values, @@ -3577,6 +4105,7 @@ impl EngineRuntime { row_id, ¤t_row.values, &next_values, + None, )? && !stale_indexes.contains(&index.name) { stale_indexes.push(index.name.clone()); @@ -3654,8 +4183,14 @@ impl EngineRuntime { .table_schema(&table_name) .cloned() .ok_or_else(|| DbError::sql(format!("unknown table {}", table_name)))?; - let matching_row_ids = - matching_row_ids(self, &table_name, &table, statement.filter.as_ref(), params)?; + let matching_row_ids = matching_row_ids( + self, + &table_name, + &table_name, + &table, + statement.filter.as_ref(), + params, + )?; let restrict_children_prepared = if table.temporary { Some(Vec::new()) } else { @@ -4522,6 +5057,7 @@ impl EngineRuntime { row_id, ¤t_row.values, &next_values, + None, )? { stale_indexes.push(index.name.clone()); } @@ -4554,6 +5090,7 @@ impl EngineRuntime { row_id, ¤t_row.values, &next_values, + None, )? { stale_indexes.push(index.name.clone()); } @@ -4626,19 +5163,7 @@ impl EngineRuntime { return Ok(()); } for child in delete_children { - let matching_children = - matching_foreign_key_children_for_parent_rows(self, table, rows, child)?; - if matching_children.is_empty() { - continue; - } match child.foreign_key.on_delete { - crate::catalog::ForeignKeyAction::NoAction - | crate::catalog::ForeignKeyAction::Restrict => { - return Err(DbError::constraint(format!( - "DELETE on {} violates a foreign key from {}", - table_name, child.child_table.name - ))) - } crate::catalog::ForeignKeyAction::Cascade => { let child_delete_children = match delete_children_by_table .and_then(|cache| cache.get(&child.child_table.name)) @@ -4650,16 +5175,6 @@ impl EngineRuntime { Cow::Owned(collect_parent_delete_children(self, &child.child_table)?) } }; - self.apply_parent_delete_actions_rows( - &child.child_table.name, - &child.child_table, - &matching_children, - child_delete_children.as_ref(), - delete_children_by_table, - table_indexes_by_table, - params, - page_size, - )?; let child_table_indexes = match table_indexes_by_table .and_then(|cache| cache.get(&child.child_table.name)) { @@ -4675,29 +5190,167 @@ impl EngineRuntime { .collect::>(), ), }; - let stale_indexes = incremental_delete_indexes( + if can_apply_terminal_cascade_delete_by_row_id( self, - &child.child_table, + child, + child_delete_children.as_ref(), child_table_indexes.as_ref(), + ) { + let deleted_child_row_ids = + matching_foreign_key_child_row_ids_for_parent_rows_by_index( + self, rows, child, + )?; + if deleted_child_row_ids.is_empty() { + continue; + } + let row_source = self + .table_row_source(&child.child_table.name) + .cloned() + .ok_or_else(|| { + DbError::internal(format!( + "table data for {} is missing", + child.child_table.name + )) + })?; + let stale_indexes = incremental_delete_indexes_by_row_id( + self, + &child.child_table, + child_table_indexes.as_ref(), + &deleted_child_row_ids, + &row_source, + )?; + self.apply_delete_row_ids_to_table_row_source( + &child.child_table.name, + &deleted_child_row_ids, + )?; + if !stale_indexes.is_empty() { + self.mark_named_indexes_stale(&stale_indexes); + } + self.mark_table_rows_deleted( + &child.child_table.name, + &deleted_child_row_ids, + ); + continue; + } + if can_apply_row_id_parent_cascade_delete( + self, + child, + child_delete_children.as_ref(), + child_table_indexes.as_ref(), + ) { + let deleted_child_row_ids = + matching_foreign_key_child_row_ids_for_parent_rows_by_index( + self, rows, child, + )?; + if deleted_child_row_ids.is_empty() { + continue; + } + let synthetic_child_rows = synthesize_row_id_parent_rows( + &child.child_table, + &deleted_child_row_ids, + )?; + self.apply_parent_delete_actions_rows( + &child.child_table.name, + &child.child_table, + &synthetic_child_rows, + child_delete_children.as_ref(), + delete_children_by_table, + table_indexes_by_table, + params, + page_size, + )?; + let row_source = self + .table_row_source(&child.child_table.name) + .cloned() + .ok_or_else(|| { + DbError::internal(format!( + "table data for {} is missing", + child.child_table.name + )) + })?; + let stale_indexes = incremental_delete_indexes_by_row_id( + self, + &child.child_table, + child_table_indexes.as_ref(), + &deleted_child_row_ids, + &row_source, + )?; + self.apply_delete_row_ids_to_table_row_source( + &child.child_table.name, + &deleted_child_row_ids, + )?; + if !stale_indexes.is_empty() { + self.mark_named_indexes_stale(&stale_indexes); + } + self.mark_table_rows_deleted( + &child.child_table.name, + &deleted_child_row_ids, + ); + continue; + } + + let matching_children = + matching_foreign_key_children_for_parent_rows(self, table, rows, child)?; + if matching_children.is_empty() { + continue; + } + self.apply_parent_delete_actions_rows( + &child.child_table.name, + &child.child_table, &matching_children, + child_delete_children.as_ref(), + delete_children_by_table, + table_indexes_by_table, + params, + page_size, )?; - let row_changes = matching_children + let deleted_child_row_ids = matching_children .iter() - .map(|row| (row.row_id, None)) - .collect::>(); - self.apply_row_changes_to_table_row_source( + .map(|row| row.row_id) + .collect::>(); + let row_source = self + .table_row_source(&child.child_table.name) + .cloned() + .ok_or_else(|| { + DbError::internal(format!( + "table data for {} is missing", + child.child_table.name + )) + })?; + let stale_indexes = incremental_delete_indexes_by_row_id( + self, + &child.child_table, + child_table_indexes.as_ref(), + &deleted_child_row_ids, + &row_source, + )?; + self.apply_delete_row_ids_to_table_row_source( &child.child_table.name, - &row_changes, - page_size, + &deleted_child_row_ids, )?; if !stale_indexes.is_empty() { self.mark_named_indexes_stale(&stale_indexes); } - for child_row in &matching_children { - self.mark_table_row_deleted(&child.child_table.name, child_row.row_id); + self.mark_table_rows_deleted(&child.child_table.name, &deleted_child_row_ids); + } + crate::catalog::ForeignKeyAction::NoAction + | crate::catalog::ForeignKeyAction::Restrict => { + let matching_children = + matching_foreign_key_children_for_parent_rows(self, table, rows, child)?; + if matching_children.is_empty() { + continue; } + return Err(DbError::constraint(format!( + "DELETE on {} violates a foreign key from {}", + table_name, child.child_table.name + ))); } crate::catalog::ForeignKeyAction::SetNull => { + let matching_children = + matching_foreign_key_children_for_parent_rows(self, table, rows, child)?; + if matching_children.is_empty() { + continue; + } let mut row_changes = BTreeMap::new(); for child_row in matching_children { let mut updated_values = child_row.values.clone(); @@ -5162,6 +5815,51 @@ fn compile_int_arithmetic_update( } } +fn compile_prepared_bool_update( + statement: &UpdateStatement, + table: &crate::catalog::TableSchema, + assignment_columns: &[usize], +) -> Option { + let ([assignment], [assignment_column]) = (&statement.assignments[..], assignment_columns) + else { + return None; + }; + + let column = table.columns.get(*assignment_column)?; + if column.column_type != ColumnType::Bool { + return None; + } + + let value_source = compile_prepared_simple_value_source(&assignment.expr)?; + + Some(PreparedBoolUpdate { + column_index: *assignment_column, + value_source, + }) +} + +fn resolve_simple_update_assignments( + table: &crate::catalog::TableSchema, + assignments: &[Assignment], + assignment_columns: &[usize], + params: &[Value], +) -> Result>> { + let mut resolved = Vec::with_capacity(assignments.len()); + for (assignment, column_index) in assignments.iter().zip(assignment_columns) { + let Some(source) = compile_prepared_simple_value_source(&assignment.expr) else { + return Ok(None); + }; + let column = table + .columns + .get(*column_index) + .ok_or_else(|| DbError::internal("assignment column index is out of range"))?; + let value = resolve_prepared_simple_value(&source, params)?; + let value = super::constraints::coerce_column_value(column, value)?; + resolved.push((*column_index, value)); + } + Ok(Some(resolved)) +} + pub(super) fn build_insert_row_values( runtime: &EngineRuntime, table: &mut crate::catalog::TableSchema, @@ -5324,7 +6022,6 @@ fn prepare_btree_insert_index( ) -> Result> { if index.kind != IndexKind::Btree || !index.fresh - || index.predicate_sql.is_some() || !matches!( runtime.index(&index.name), Some(super::RuntimeIndex::Btree { .. }) @@ -5344,7 +6041,7 @@ fn prepare_btree_insert_index( let column_index = table .columns .iter() - .position(|entry| entry.name == *column_name) + .position(|entry| identifiers_equal(&entry.name, column_name)) .ok_or_else(|| { DbError::constraint(format!("index column {} does not exist", column_name)) })?; @@ -5357,6 +6054,11 @@ fn prepare_btree_insert_index( let uuid_key = index.columns.len() == 1 && table.columns[column_indexes[0]].column_type == ColumnType::Uuid && !table.columns[column_indexes[0]].nullable; + let predicate_expr = index + .predicate_sql + .as_deref() + .map(parse_expression_sql) + .transpose()?; Ok(Some(PreparedBtreeIndex { name: index.name.clone(), @@ -5375,11 +6077,12 @@ fn prepare_btree_insert_index( table .columns .iter() - .find(|entry| entry.name == *column_name) + .find(|entry| identifiers_equal(&entry.name, column_name)) }) .is_some_and(|column| column.nullable) }), unique: index.unique, + predicate_expr, })) } @@ -5459,6 +6162,9 @@ fn prepare_foreign_key( else { return Ok(None); }; + if prepared_parent_index.predicate_expr.is_some() { + return Ok(None); + } Ok(Some(PreparedForeignKey { child_column_indexes, parent_table_name: foreign_key.referenced_table.clone(), @@ -5481,11 +6187,29 @@ fn validate_prepared_insert( } } if prepared.use_generic_index_updates { + let table = + if prepared + .unique_indexes + .iter() + .any(|index| index.predicate_expr.is_some()) + { + Some(runtime.table_schema(&prepared.table_name).ok_or_else(|| { + DbError::sql(format!("unknown table {}", prepared.table_name)) + })?) + } else { + None + }; for index in &prepared.unique_indexes { - if prepared_index_contains_null(index, row) { + let Some(key) = prepared_btree_index_key_if_row_should_be_indexed( + runtime, + table, + &prepared.table_name, + index, + row, + )? + else { continue; - } - let key = prepared_btree_index_key(index, row)?; + }; let Some(super::RuntimeIndex::Btree { keys, .. }) = runtime.index(&index.name) else { return Err(DbError::internal(format!( "runtime index {} is missing", @@ -5674,6 +6398,41 @@ fn resolve_prepared_insert_value_source( } } +fn apply_prepared_generated_columns( + runtime: &EngineRuntime, + prepared: &PreparedSimpleInsert, + row: &mut [Value], + params: &[Value], +) -> Result<()> { + if prepared.generated_columns.is_empty() { + return Ok(()); + } + + let table = runtime + .table_schema(prepared.table_name.as_str()) + .ok_or_else(|| DbError::sql(format!("unknown table {}", prepared.table_name)))?; + for generated in &prepared.generated_columns { + let column = table.columns.get(generated.index).ok_or_else(|| { + DbError::internal("prepared generated column index exceeded table width") + })?; + let dataset = table_row_dataset(table, row, &table.name); + let value = runtime.eval_expr( + &generated.expr, + &dataset, + row, + params, + &std::collections::BTreeMap::new(), + None, + )?; + let slot = row.get_mut(generated.index).ok_or_else(|| { + DbError::internal("prepared generated column index exceeded row width") + })?; + *slot = super::constraints::coerce_column_value(column, value)?; + } + + Ok(()) +} + fn materialize_direct_positional_insert_candidate( prepared: &PreparedSimpleInsert, params: &[Value], @@ -5830,7 +6589,7 @@ fn apply_prepared_insert_index_updates( let table = if prepared .insert_indexes .iter() - .any(|index| index.has_covering_payload) + .any(|index| index.has_covering_payload || index.predicate_expr.is_some()) { Some( runtime @@ -5849,19 +6608,20 @@ fn apply_prepared_insert_index_updates( .and_then(|table_name| runtime.tables.get(table_name)) .is_none_or(|row_source| row_source.has_tombstoned_rows()); for index in &prepared.insert_indexes { + let Some(key) = prepared_btree_index_key_if_row_should_be_indexed( + runtime, + table.as_ref(), + &prepared.table_name, + index, + &row.values, + )? + else { + continue; + }; if index.int64_key { - let [column_index] = index.column_indexes.as_slice() else { - return Err(DbError::internal( - "typed INT64 prepared index expected exactly one indexed column", - )); - }; - let Value::Int64(key) = row - .values - .get(*column_index) - .ok_or_else(|| DbError::internal("row is shorter than prepared insert plan"))? - else { + let RuntimeBtreeKey::Int64(key) = key else { return Err(DbError::internal( - "typed INT64 prepared index expected an INT64 value", + "typed INT64 prepared index produced a non-INT64 key", )); }; let covering_values = if let Some(table) = table.as_ref() { @@ -5880,7 +6640,7 @@ fn apply_prepared_insert_index_updates( runtime, &prepared.table_name, &index.name, - &RuntimeBtreeKey::Int64(*key), + &RuntimeBtreeKey::Int64(key), )?; } let Some(super::RuntimeIndex::Btree { keys, covering }) = @@ -5891,7 +6651,7 @@ fn apply_prepared_insert_index_updates( index.name ))); }; - let key = RuntimeBtreeKey::Int64(*key); + let key = RuntimeBtreeKey::Int64(key); if check_unique && index.unique { if keys.insert_row_id(key, row.row_id).is_err() { return Err(DbError::constraint(format!( @@ -5908,18 +6668,9 @@ fn apply_prepared_insert_index_updates( continue; } if index.uuid_key { - let [column_index] = index.column_indexes.as_slice() else { - return Err(DbError::internal( - "typed UUID prepared index expected exactly one indexed column", - )); - }; - let Value::Uuid(key) = row - .values - .get(*column_index) - .ok_or_else(|| DbError::internal("row is shorter than prepared insert plan"))? - else { + let RuntimeBtreeKey::Uuid(key) = key else { return Err(DbError::internal( - "typed UUID prepared index expected a UUID value", + "typed UUID prepared index produced a non-UUID key", )); }; let covering_values = if let Some(table) = table.as_ref() { @@ -5938,7 +6689,7 @@ fn apply_prepared_insert_index_updates( runtime, &prepared.table_name, &index.name, - &RuntimeBtreeKey::Uuid(*key), + &RuntimeBtreeKey::Uuid(key), )?; } let Some(super::RuntimeIndex::Btree { keys, covering }) = @@ -5949,7 +6700,7 @@ fn apply_prepared_insert_index_updates( index.name ))); }; - let key = RuntimeBtreeKey::Uuid(*key); + let key = RuntimeBtreeKey::Uuid(key); if check_unique && index.unique { if keys.insert_row_id(key, row.row_id).is_err() { return Err(DbError::constraint(format!( @@ -5966,10 +6717,6 @@ fn apply_prepared_insert_index_updates( continue; } - if index.unique && prepared_index_contains_null(index, &row.values) { - continue; - } - let key = prepared_btree_index_key(index, &row.values)?; let covering_values = if let Some(table) = table.as_ref() { runtime .catalog @@ -5996,7 +6743,7 @@ fn apply_prepared_insert_index_updates( index.name ))); }; - if check_unique && index.unique && !prepared_index_contains_null(index, &row.values) { + if check_unique && index.unique { if keys.insert_row_id(key, row.row_id).is_err() { return Err(DbError::constraint(format!( "unique constraint {} on {} was violated", @@ -6082,6 +6829,45 @@ fn prepared_index_contains_null(index: &PreparedBtreeIndex, row: &[Value]) -> bo .any(|&column_index| matches!(row.get(column_index), Some(Value::Null))) } +fn prepared_index_row_satisfies_predicate( + runtime: &EngineRuntime, + table: &crate::catalog::TableSchema, + index: &PreparedBtreeIndex, + row: &[Value], +) -> Result { + let Some(predicate_expr) = index.predicate_expr.as_ref() else { + return Ok(true); + }; + let Some(index_schema) = runtime.catalog.indexes.get(&index.name) else { + return Err(DbError::internal(format!( + "catalog index {} is missing", + index.name + ))); + }; + row_satisfies_index_predicate_with_expr(runtime, index_schema, table, row, Some(predicate_expr)) +} + +fn prepared_btree_index_key_if_row_should_be_indexed( + runtime: &EngineRuntime, + table: Option<&crate::catalog::TableSchema>, + table_name: &str, + index: &PreparedBtreeIndex, + row: &[Value], +) -> Result> { + if index.predicate_expr.is_some() { + let table = table.ok_or_else(|| { + DbError::internal(format!("table schema for {table_name} is missing")) + })?; + if !prepared_index_row_satisfies_predicate(runtime, table, index, row)? { + return Ok(None); + } + } + if index.unique && prepared_index_contains_null(index, row) { + return Ok(None); + } + prepared_btree_index_key(index, row).map(Some) +} + fn prepared_index_covering_payload_values( index: &PreparedBtreeIndex, row_values: &[Value], @@ -6110,25 +6896,40 @@ fn apply_prepared_btree_index_update_for_row_change( old_row_values: &[Value], new_row_values: &[Value], ) -> Result { - let old_key = if index.unique && prepared_index_contains_null(index, old_row_values) { - None + let table = if index.predicate_expr.is_some() { + Some( + runtime + .table_schema(table_name) + .ok_or_else(|| DbError::sql(format!("unknown table {table_name}")))?, + ) } else { - Some(prepared_btree_index_key(index, old_row_values)?) - }; - let new_key = if index.unique && prepared_index_contains_null(index, new_row_values) { None - } else { - Some(prepared_btree_index_key(index, new_row_values)?) }; + let old_key = prepared_btree_index_key_if_row_should_be_indexed( + runtime, + table, + table_name, + index, + old_row_values, + )?; + let new_key = prepared_btree_index_key_if_row_should_be_indexed( + runtime, + table, + table_name, + index, + new_row_values, + )?; let Some(super::RuntimeIndex::Btree { keys, covering }) = runtime.index_mut(&index.name) else { return Ok(false); }; if old_key == new_key { - let covering_values = prepared_index_covering_payload_values(index, new_row_values)?; - if let (Some(covering), Some(values)) = (covering.as_mut(), covering_values) { - covering.insert_row_values(row_id, values); + if old_key.is_some() { + let covering_values = prepared_index_covering_payload_values(index, new_row_values)?; + if let (Some(covering), Some(values)) = (covering.as_mut(), covering_values) { + covering.insert_row_values(row_id, values); + } } return Ok(true); } @@ -6234,19 +7035,43 @@ pub(super) fn primary_row_id(table: &crate::catalog::TableSchema, row: &[Value]) } } -fn matching_row_ids( +pub(super) fn matching_row_ids( runtime: &EngineRuntime, - table_ref: &str, + table_name: &str, + filter_table_ref: &str, table: &crate::catalog::TableSchema, filter: Option<&Expr>, params: &[Value], ) -> Result> { if let Some(indexed_row_ids) = - indexed_row_ids_for_filter(runtime, table_ref, table, filter, params)? + indexed_row_ids_for_filter(runtime, filter_table_ref, table, filter, params)? { - return Ok(indexed_row_ids); + let Some(row_source) = runtime.visible_table_row_source(table_name) else { + return Ok(Vec::new()); + }; + if indexed_row_ids.fully_covers_filter && !row_source.has_tombstoned_rows() { + return Ok(indexed_row_ids.row_ids); + } + let mut matching = Vec::new(); + for row_id in indexed_row_ids.row_ids { + let Some(row) = row_source.row_by_id(row_id)? else { + continue; + }; + if indexed_row_ids.fully_covers_filter { + matching.push(row_id); + continue; + } + let candidate = StoredRow { + row_id, + values: row.values().to_vec(), + }; + if row_matches_filter(runtime, filter_table_ref, table, &candidate, filter, params)? { + matching.push(row_id); + } + } + return Ok(matching); } - let Some(row_source) = runtime.visible_table_row_source(table_ref) else { + let Some(row_source) = runtime.visible_table_row_source(table_name) else { return Ok(Vec::new()); }; let mut matching = Vec::new(); @@ -6256,90 +7081,502 @@ fn matching_row_ids( row_id: row.row_id(), values: row.values().to_vec(), }; - if row_matches_filter(runtime, table, &candidate, filter, params)? { + if row_matches_filter(runtime, filter_table_ref, table, &candidate, filter, params)? { matching.push(row.row_id()); } } - Ok(matching) + Ok(matching) +} + +#[derive(Debug)] +struct IndexedFilterRowIds { + row_ids: Vec, + fully_covers_filter: bool, +} + +fn indexed_row_ids_for_filter( + runtime: &EngineRuntime, + table_ref: &str, + table: &crate::catalog::TableSchema, + filter: Option<&Expr>, + params: &[Value], +) -> Result> { + if !generated_columns_are_stored(table) { + return Ok(None); + } + let Some(filter) = filter else { + return Ok(None); + }; + if let Some(row_ids) = + row_id_range_row_ids_for_filter(runtime, table_ref, table, filter, params)? + { + return Ok(Some(IndexedFilterRowIds { + row_ids, + fully_covers_filter: true, + })); + } + if let Some(row_ids) = + compound_btree_range_row_ids_for_filter(runtime, table_ref, table, filter, params)? + { + return Ok(Some(row_ids)); + } + let Some((filter_table, column_name, value_expr)) = simple_btree_lookup_filter(filter) else { + return Ok(None); + }; + if let Some(filter_table) = filter_table { + if !identifiers_equal(filter_table, &table.name) + && !identifiers_equal(filter_table, table_ref) + { + return Ok(None); + } + } + let value = runtime.eval_expr( + value_expr, + &Dataset::empty(), + &[], + params, + &std::collections::BTreeMap::new(), + None, + )?; + if matches!(value, Value::Null) { + return Ok(Some(IndexedFilterRowIds { + row_ids: Vec::new(), + fully_covers_filter: true, + })); + } + if row_id_alias_column_name(table).is_some_and(|entry| identifiers_equal(entry, column_name)) { + return Ok(Some(IndexedFilterRowIds { + row_ids: match value { + Value::Int64(row_id) => match runtime.visible_table_row_source(table_ref) { + Some(row_source) if row_source.row_by_id(row_id)?.is_some() => vec![row_id], + _ => Vec::new(), + }, + _ => Vec::new(), + }, + fully_covers_filter: true, + })); + } + if runtime.visible_table_is_temporary(table_ref) { + return Ok(None); + } + + let Some(index) = runtime.catalog.indexes.values().find(|index| { + identifiers_equal(&index.table_name, &table.name) + && index.fresh + && index.kind == IndexKind::Btree + && index.predicate_sql.is_none() + && index.columns.len() == 1 + && index.columns[0].expression_sql.is_none() + && index.columns[0] + .column_name + .as_ref() + .is_some_and(|entry| identifiers_equal(entry, column_name)) + }) else { + return Ok(None); + }; + let Some(RuntimeIndex::Btree { keys, .. }) = runtime.index(&index.name) else { + return Ok(None); + }; + if matches!( + keys, + super::RuntimeBtreeKeys::UniqueInt64(..) | super::RuntimeBtreeKeys::NonUniqueInt64(..) + ) && !matches!(value, Value::Int64(_)) + { + return Ok(None); + } + Ok(Some(IndexedFilterRowIds { + row_ids: row_id_set_to_vec(keys.row_ids_for_value_set(&value)?), + fully_covers_filter: true, + })) +} + +#[derive(Clone, Debug)] +struct DmlRangeBoundValue { + value: Value, + inclusive: bool, +} + +fn compound_btree_range_row_ids_for_filter( + runtime: &EngineRuntime, + table_ref: &str, + table: &crate::catalog::TableSchema, + filter: &Expr, + params: &[Value], +) -> Result> { + if runtime.visible_table_is_temporary(table_ref) { + return Ok(None); + } + let predicates = dml_flattened_and_predicates(filter); + + for index in runtime.catalog.indexes.values() { + if !identifiers_equal(&index.table_name, &table.name) + || !index.fresh + || index.kind != IndexKind::Btree + || index.predicate_sql.is_some() + || index.columns.len() < 2 + || index + .columns + .iter() + .any(|column| column.expression_sql.is_some() || column.column_name.is_none()) + { + continue; + } + + let mut prefix_values = Vec::new(); + for index_column in &index.columns { + let Some(column_name) = index_column.column_name.as_deref() else { + break; + }; + let Some(table_column) = table + .columns + .iter() + .find(|column| identifiers_equal(&column.name, column_name)) + else { + break; + }; + let Some(value_expr) = + dml_equality_expr_for_column(&predicates, table_ref, table, column_name) + else { + break; + }; + let value = runtime.eval_expr( + value_expr, + &Dataset::empty(), + &[], + params, + &std::collections::BTreeMap::new(), + None, + )?; + if matches!(value, Value::Null) { + return Ok(Some(IndexedFilterRowIds { + row_ids: Vec::new(), + fully_covers_filter: true, + })); + } + prefix_values.push(super::constraints::coerce_column_value( + table_column, + value, + )?); + } + + if prefix_values.is_empty() || prefix_values.len() >= index.columns.len() { + continue; + } + + let range_column_name = index.columns[prefix_values.len()] + .column_name + .as_deref() + .ok_or_else(|| DbError::internal("prepared compound index column missing name"))?; + let Some(range_column) = table + .columns + .iter() + .find(|column| identifiers_equal(&column.name, range_column_name)) + else { + continue; + }; + let Some((lower, upper)) = dml_range_bounds_for_column( + runtime, + &predicates, + table_ref, + table, + range_column_name, + range_column, + params, + )? + else { + continue; + }; + let Some(RuntimeIndex::Btree { keys, .. }) = runtime.index(&index.name) else { + continue; + }; + let used_predicate_count = + prefix_values.len() + usize::from(lower.is_some()) + usize::from(upper.is_some()); + return Ok(Some(IndexedFilterRowIds { + row_ids: compound_btree_range_row_ids( + keys, + &prefix_values, + prefix_values.len(), + range_column.column_type, + lower.as_ref(), + upper.as_ref(), + )?, + fully_covers_filter: used_predicate_count == predicates.len(), + })); + } + + Ok(None) +} + +fn dml_flattened_and_predicates(expr: &Expr) -> Vec<&Expr> { + match expr { + Expr::Binary { + left, + op: BinaryOp::And, + right, + } => { + let mut predicates = dml_flattened_and_predicates(left); + predicates.extend(dml_flattened_and_predicates(right)); + predicates + } + _ => vec![expr], + } +} + +fn dml_filter_table_matches( + filter_table: Option<&str>, + table_ref: &str, + table: &crate::catalog::TableSchema, +) -> bool { + filter_table.is_none_or(|name| { + identifiers_equal(name, &table.name) || identifiers_equal(name, table_ref) + }) +} + +fn dml_equality_expr_for_column<'a>( + predicates: &[&'a Expr], + table_ref: &str, + table: &crate::catalog::TableSchema, + column_name: &str, +) -> Option<&'a Expr> { + predicates.iter().find_map(|predicate| { + let (filter_table, candidate_column, value_expr) = simple_btree_lookup_filter(predicate)?; + (identifiers_equal(candidate_column, column_name) + && dml_filter_table_matches(filter_table, table_ref, table)) + .then_some(value_expr) + }) +} + +fn dml_range_bounds_for_column( + runtime: &EngineRuntime, + predicates: &[&Expr], + table_ref: &str, + table: &crate::catalog::TableSchema, + column_name: &str, + column: &crate::catalog::ColumnSchema, + params: &[Value], +) -> Result, Option)>> { + let mut lower = None; + let mut upper = None; + for predicate in predicates { + let Some((filter_table, candidate_column, kind, value_expr)) = + dml_simple_range_bound(predicate) + else { + continue; + }; + if !identifiers_equal(candidate_column, column_name) + || !dml_filter_table_matches(filter_table, table_ref, table) + { + continue; + } + let value = runtime.eval_expr( + value_expr, + &Dataset::empty(), + &[], + params, + &std::collections::BTreeMap::new(), + None, + )?; + if matches!(value, Value::Null) { + return Ok(None); + } + let value = super::constraints::coerce_column_value(column, value)?; + match kind { + DmlRangeBoundKind::Lower(inclusive) => { + if lower.is_some() { + return Ok(None); + } + lower = Some(DmlRangeBoundValue { value, inclusive }); + } + DmlRangeBoundKind::Upper(inclusive) => { + if upper.is_some() { + return Ok(None); + } + upper = Some(DmlRangeBoundValue { value, inclusive }); + } + } + } + + if lower.is_none() && upper.is_none() { + Ok(None) + } else { + Ok(Some((lower, upper))) + } +} + +#[derive(Clone, Copy, Debug)] +enum DmlRangeBoundKind { + Lower(bool), + Upper(bool), +} + +fn dml_simple_range_bound( + predicate: &Expr, +) -> Option<(Option<&str>, &str, DmlRangeBoundKind, &Expr)> { + let Expr::Binary { left, op, right } = predicate else { + return None; + }; + dml_range_bound_from_column_left(left, *op, right) + .or_else(|| dml_range_bound_from_column_left(right, reverse_dml_range_op(*op)?, left)) +} + +fn dml_range_bound_from_column_left<'a>( + left: &'a Expr, + op: BinaryOp, + right: &'a Expr, +) -> Option<(Option<&'a str>, &'a str, DmlRangeBoundKind, &'a Expr)> { + let Expr::Column { table, column } = left else { + return None; + }; + if !simple_constant_bound_expr(right) { + return None; + } + let kind = match op { + BinaryOp::Gt => DmlRangeBoundKind::Lower(false), + BinaryOp::GtEq => DmlRangeBoundKind::Lower(true), + BinaryOp::Lt => DmlRangeBoundKind::Upper(false), + BinaryOp::LtEq => DmlRangeBoundKind::Upper(true), + _ => return None, + }; + Some((table.as_deref(), column.as_str(), kind, right)) +} + +fn reverse_dml_range_op(op: BinaryOp) -> Option { + match op { + BinaryOp::Gt => Some(BinaryOp::Lt), + BinaryOp::GtEq => Some(BinaryOp::LtEq), + BinaryOp::Lt => Some(BinaryOp::Gt), + BinaryOp::LtEq => Some(BinaryOp::GtEq), + _ => None, + } +} + +fn compound_btree_range_row_ids( + keys: &super::RuntimeBtreeKeys, + prefix_values: &[Value], + range_column_position: usize, + range_column_type: ColumnType, + lower: Option<&DmlRangeBoundValue>, + upper: Option<&DmlRangeBoundValue>, +) -> Result> { + let mut row_ids = Vec::new(); + match keys { + super::RuntimeBtreeKeys::UniqueEncoded(entries, deleted) => { + let mut saw_matching_prefix = false; + for (encoded_key, row_id) in entries.iter() { + if !Row::encoded_prefix_matches(encoded_key, prefix_values)? { + if saw_matching_prefix { + break; + } + continue; + } + saw_matching_prefix = true; + if deleted.contains(row_id) + || !compound_range_value_matches( + encoded_key, + range_column_position, + range_column_type, + lower, + upper, + )? + { + continue; + } + row_ids.push(*row_id); + } + } + super::RuntimeBtreeKeys::NonUniqueEncoded(entries, deleted) => { + let mut saw_matching_prefix = false; + for (encoded_key, entry_row_ids) in entries.iter() { + if !Row::encoded_prefix_matches(encoded_key, prefix_values)? { + if saw_matching_prefix { + break; + } + continue; + } + saw_matching_prefix = true; + if !compound_range_value_matches( + encoded_key, + range_column_position, + range_column_type, + lower, + upper, + )? { + continue; + } + row_ids.extend( + entry_row_ids + .iter() + .copied() + .filter(|row_id| !deleted.contains(row_id)), + ); + } + } + super::RuntimeBtreeKeys::UniqueInt64(_, _) + | super::RuntimeBtreeKeys::NonUniqueInt64(_, _) + | super::RuntimeBtreeKeys::UniqueUuid(_, _) + | super::RuntimeBtreeKeys::NonUniqueUuid(_, _) => return Ok(Vec::new()), + } + row_ids.sort_unstable(); + row_ids.dedup(); + Ok(row_ids) +} + +fn compound_range_value_matches( + encoded_key: &[u8], + range_column_position: usize, + range_column_type: ColumnType, + lower: Option<&DmlRangeBoundValue>, + upper: Option<&DmlRangeBoundValue>, +) -> Result { + match range_column_type { + ColumnType::Float64 => { + let Some(value) = Row::decode_float64_at(encoded_key, range_column_position)? else { + return Ok(false); + }; + return dml_value_within_range(&Value::Float64(value), lower, upper); + } + ColumnType::Int64 => { + let Some(value) = Row::decode_int64_at(encoded_key, range_column_position)? else { + return Ok(false); + }; + return dml_value_within_range(&Value::Int64(value), lower, upper); + } + _ => {} + } + let range_values = Row::decode_projection_with_overflow::< + crate::storage::page::InMemoryPageStore, + >(encoded_key, None, &[range_column_position])?; + let Some(range_value) = range_values.first() else { + return Ok(false); + }; + dml_value_within_range(range_value, lower, upper) } -fn indexed_row_ids_for_filter( - runtime: &EngineRuntime, - table_ref: &str, - table: &crate::catalog::TableSchema, - filter: Option<&Expr>, - params: &[Value], -) -> Result>> { - if !generated_columns_are_stored(table) { - return Ok(None); - } - let Some(filter) = filter else { - return Ok(None); - }; - if let Some(row_ids) = - row_id_range_row_ids_for_filter(runtime, table_ref, table, filter, params)? - { - return Ok(Some(row_ids)); +fn dml_value_within_range( + value: &Value, + lower: Option<&DmlRangeBoundValue>, + upper: Option<&DmlRangeBoundValue>, +) -> Result { + if matches!(value, Value::Null) { + return Ok(false); } - let Some((filter_table, column_name, value_expr)) = simple_btree_lookup_filter(filter) else { - return Ok(None); - }; - if let Some(filter_table) = filter_table { - if !identifiers_equal(filter_table, &table.name) - && !identifiers_equal(filter_table, table_ref) + if let Some(bound) = lower { + let ordering = compare_values(value, &bound.value)?; + if ordering == std::cmp::Ordering::Less + || (!bound.inclusive && ordering == std::cmp::Ordering::Equal) { - return Ok(None); + return Ok(false); } } - let value = runtime.eval_expr( - value_expr, - &Dataset::empty(), - &[], - params, - &std::collections::BTreeMap::new(), - None, - )?; - if matches!(value, Value::Null) { - return Ok(Some(Vec::new())); - } - if row_id_alias_column_name(table).is_some_and(|entry| identifiers_equal(entry, column_name)) { - return Ok(Some(match value { - Value::Int64(row_id) => match runtime.visible_table_row_source(table_ref) { - Some(row_source) if row_source.row_by_id(row_id)?.is_some() => vec![row_id], - _ => Vec::new(), - }, - _ => Vec::new(), - })); - } - if runtime.visible_table_is_temporary(table_ref) { - return Ok(None); - } - - let Some(index) = runtime.catalog.indexes.values().find(|index| { - identifiers_equal(&index.table_name, &table.name) - && index.fresh - && index.kind == IndexKind::Btree - && index.predicate_sql.is_none() - && index.columns.len() == 1 - && index.columns[0].expression_sql.is_none() - && index.columns[0] - .column_name - .as_ref() - .is_some_and(|entry| identifiers_equal(entry, column_name)) - }) else { - return Ok(None); - }; - let Some(RuntimeIndex::Btree { keys, .. }) = runtime.index(&index.name) else { - return Ok(None); - }; - if matches!( - keys, - super::RuntimeBtreeKeys::UniqueInt64(..) | super::RuntimeBtreeKeys::NonUniqueInt64(..) - ) && !matches!(value, Value::Int64(_)) - { - return Ok(None); + if let Some(bound) = upper { + let ordering = compare_values(value, &bound.value)?; + if ordering == std::cmp::Ordering::Greater + || (!bound.inclusive && ordering == std::cmp::Ordering::Equal) + { + return Ok(false); + } } - Ok(Some(row_id_set_to_vec(keys.row_ids_for_value_set(&value)?))) + Ok(true) } fn row_id_range_row_ids_for_filter( @@ -6421,6 +7658,14 @@ pub(crate) fn row_id_alias_column_name(table: &crate::catalog::TableSchema) -> O .map(|column| column.name.as_str()) } +fn row_id_alias_column_index(table: &crate::catalog::TableSchema) -> Option { + let row_id_alias = row_id_alias_column_name(table)?; + table + .columns + .iter() + .position(|column| identifiers_equal(&column.name, row_id_alias)) +} + fn simple_row_id_between_filter(filter: &Expr) -> Option<(Option<&str>, &str, &Expr, &Expr)> { let Expr::Between { expr, @@ -6966,6 +8211,180 @@ fn prepare_delete_children_and_indexes( Ok((delete_children_by_table, table_indexes_by_table)) } +fn can_apply_terminal_cascade_delete_by_row_id( + runtime: &EngineRuntime, + child: &PreparedDeleteCascadeChild, + child_delete_children: &[PreparedDeleteCascadeChild], + child_table_indexes: &[IndexSchema], +) -> bool { + if !child_delete_children.is_empty() + || runtime.has_table_trigger(&child.child_table.name, TriggerEvent::Delete) + { + return false; + } + if child.foreign_key.columns.len() != 1 + || child.parent_column_indexes.len() != 1 + || child.child_column_indexes.len() != 1 + || child.child_index_prefix_len != Some(1) + { + return false; + } + let Some(index_name) = child.child_index_name.as_deref() else { + return false; + }; + let Some(index_schema) = runtime.catalog.indexes.get(index_name) else { + return false; + }; + if !index_schema.fresh + || index_schema.kind != IndexKind::Btree + || index_schema.predicate_sql.is_some() + || index_schema.columns.len() != 1 + || !index_schema.columns[0] + .column_name + .as_ref() + .is_some_and(|column_name| { + identifiers_equal(column_name, &child.foreign_key.columns[0]) + }) + || index_schema.columns[0].expression_sql.is_some() + || !matches!(runtime.index(index_name), Some(RuntimeIndex::Btree { .. })) + { + return false; + } + child_table_indexes.iter().all(|index| { + index.fresh + && index.kind == IndexKind::Btree + && index.predicate_sql.is_none() + && index + .columns + .iter() + .all(|column| column.expression_sql.is_none()) + && matches!(runtime.index(&index.name), Some(RuntimeIndex::Btree { .. })) + }) +} + +fn can_apply_row_id_parent_cascade_delete( + runtime: &EngineRuntime, + child: &PreparedDeleteCascadeChild, + child_delete_children: &[PreparedDeleteCascadeChild], + child_table_indexes: &[IndexSchema], +) -> bool { + if child_delete_children.is_empty() + || runtime.has_table_trigger(&child.child_table.name, TriggerEvent::Delete) + { + return false; + } + if child.foreign_key.columns.len() != 1 + || child.parent_column_indexes.len() != 1 + || child.child_column_indexes.len() != 1 + || child.child_index_prefix_len != Some(1) + { + return false; + } + let Some(index_name) = child.child_index_name.as_deref() else { + return false; + }; + let Some(index_schema) = runtime.catalog.indexes.get(index_name) else { + return false; + }; + if !index_schema.fresh + || index_schema.kind != IndexKind::Btree + || index_schema.predicate_sql.is_some() + || index_schema.columns.len() != 1 + || !index_schema.columns[0] + .column_name + .as_ref() + .is_some_and(|column_name| { + identifiers_equal(column_name, &child.foreign_key.columns[0]) + }) + || index_schema.columns[0].expression_sql.is_some() + || !matches!(runtime.index(index_name), Some(RuntimeIndex::Btree { .. })) + { + return false; + } + if !child_table_indexes.iter().all(|index| { + index.fresh + && index.kind == IndexKind::Btree + && matches!(runtime.index(&index.name), Some(RuntimeIndex::Btree { .. })) + }) { + return false; + } + let Some(row_id_alias_index) = row_id_alias_column_index(&child.child_table) else { + return false; + }; + child_delete_children.iter().all(|recursive_child| { + recursive_child.foreign_key.on_delete == ForeignKeyAction::Cascade + && recursive_child.parent_column_indexes.len() == 1 + && recursive_child.parent_column_indexes[0] == row_id_alias_index + }) +} + +fn synthesize_row_id_parent_rows( + table: &crate::catalog::TableSchema, + row_ids: &BTreeSet, +) -> Result> { + let Some(row_id_alias_index) = row_id_alias_column_index(table) else { + return Err(DbError::internal(format!( + "table {} has no row-id alias", + table.name + ))); + }; + let mut rows = Vec::with_capacity(row_ids.len()); + for row_id in row_ids { + let mut values = vec![Value::Null; table.columns.len()]; + values[row_id_alias_index] = Value::Int64(*row_id); + rows.push(StoredRow { + row_id: *row_id, + values, + }); + } + Ok(rows) +} + +fn matching_foreign_key_child_row_ids_for_parent_rows_by_index( + runtime: &EngineRuntime, + parent_rows: &[StoredRow], + child: &PreparedDeleteCascadeChild, +) -> Result> { + let Some(parent_column_index) = child.parent_column_indexes.first().copied() else { + return Err(DbError::internal("parent foreign-key column is missing")); + }; + let mut parent_values = Vec::with_capacity(parent_rows.len()); + for row in parent_rows { + let Some(value) = row.values.get(parent_column_index) else { + return Err(DbError::internal(format!( + "parent column index {parent_column_index} is invalid" + ))); + }; + if !matches!(value, Value::Null) { + parent_values.push(value.clone()); + } + } + if parent_values.is_empty() { + return Ok(BTreeSet::new()); + } + + let Some(index_name) = child.child_index_name.as_deref() else { + return Err(DbError::internal("child foreign-key index is missing")); + }; + let Some(RuntimeIndex::Btree { keys, .. }) = runtime.index(index_name) else { + return Err(DbError::internal( + "child foreign-key runtime index is missing", + )); + }; + let Some(row_source) = runtime.visible_table_row_source(&child.child_table.name) else { + return Ok(BTreeSet::new()); + }; + let parent_value_refs = parent_values.iter().collect::>(); + let row_ids = keys.row_ids_for_values(&parent_value_refs)?; + let mut matching = BTreeSet::new(); + for row_id in row_ids { + if row_source.row_by_id(row_id)?.is_some() { + matching.insert(row_id); + } + } + Ok(matching) +} + fn matching_foreign_key_children_for_parent_rows( runtime: &EngineRuntime, _table: &crate::catalog::TableSchema, @@ -7053,30 +8472,41 @@ fn matching_foreign_key_children_for_parent_rows( } } } else { - for parent_key in &parent_keys { - let row_ids = if child.foreign_key.columns.len() == 1 { - let Some(parent_value) = parent_key.first() else { - continue; - }; - keys.row_ids_for_value(parent_value)? - } else { - keys.row_ids_for_key(&RuntimeBtreeKey::Encoded( - Row::new(parent_key.clone()).encode()?, - )) - }; + if child.foreign_key.columns.len() == 1 { + let parent_values = parent_keys + .iter() + .filter_map(|parent_key| parent_key.first()) + .collect::>(); + let row_ids = keys.row_ids_for_values(&parent_values)?; for row_id in row_ids { let Some(row) = row_source.row_by_id(row_id)? else { continue; }; let stored_row = materialize_foreign_key_child_row(runtime, &child.child_table, row)?; - if foreign_key_child_matches_parent_key( - &stored_row.values, - &child.child_column_indexes, - parent_key, - )? { - matching_children.insert(stored_row.row_id, stored_row); - } + matching_children.insert(stored_row.row_id, stored_row); + } + } else { + for parent_key in &parent_keys { + keys.row_ids_for_key(&RuntimeBtreeKey::Encoded( + Row::new(parent_key.clone()).encode()?, + )) + .into_iter() + .try_for_each(|row_id| -> Result<()> { + let Some(row) = row_source.row_by_id(row_id)? else { + return Ok(()); + }; + let stored_row = + materialize_foreign_key_child_row(runtime, &child.child_table, row)?; + if foreign_key_child_matches_parent_key( + &stored_row.values, + &child.child_column_indexes, + parent_key, + )? { + matching_children.insert(stored_row.row_id, stored_row); + } + Ok(()) + })?; } } } @@ -7229,19 +8659,38 @@ fn apply_runtime_index_update_for_row_change( row_id: i64, old_row_values: &[Value], new_row_values: &[Value], + pre_parsed_predicate: Option<&Expr>, ) -> Result { let kind = index.kind; let _table_name = table.name.clone(); let _index_name = index.name.clone(); match kind { IndexKind::Btree => { - let old_key = compute_index_key(runtime, index, table, old_row_values)?; - let new_key = compute_index_key(runtime, index, table, new_row_values)?; - let covering_values = covering_payload_values_for_row(index, table, new_row_values); + let old_key = super::compute_index_key_with_predicate( + runtime, + index, + table, + old_row_values, + pre_parsed_predicate, + )?; + let new_key = super::compute_index_key_with_predicate( + runtime, + index, + table, + new_row_values, + pre_parsed_predicate, + )?; if old_key == new_key { - if let Some(RuntimeIndex::Btree { covering, .. }) = runtime.index_mut(&index.name) { - if let (Some(covering), Some(values)) = (covering.as_mut(), covering_values) { - covering.insert_row_values(row_id, values); + if old_key.is_some() { + if let Some(RuntimeIndex::Btree { covering, .. }) = + runtime.index_mut(&index.name) + { + let covering_values = + covering_payload_values_for_row(index, table, new_row_values); + if let (Some(covering), Some(values)) = (covering.as_mut(), covering_values) + { + covering.insert_row_values(row_id, values); + } } } return Ok(true); @@ -7267,6 +8716,7 @@ fn apply_runtime_index_update_for_row_change( (None, None) => {} } if new_key_present { + let covering_values = covering_payload_values_for_row(index, table, new_row_values); if let (Some(covering), Some(values)) = (covering.as_mut(), covering_values) { covering.insert_row_values(row_id, values); } @@ -7731,6 +9181,7 @@ fn incremental_update_indexes( old_row.row_id, &old_row.values, &new_row.values, + None, )? { failed = true; break; @@ -7803,6 +9254,7 @@ fn trigram_index_text_for_row_with_expr( fn row_matches_filter( runtime: &EngineRuntime, + table_ref: &str, table: &crate::catalog::TableSchema, row: &StoredRow, filter: Option<&Expr>, @@ -7812,7 +9264,7 @@ fn row_matches_filter( return Ok(true); }; let eval_values = materialize_row_for_generated(runtime, table, &row.values)?; - let dataset = table_row_dataset(table, eval_values.as_ref(), &table.name); + let dataset = table_row_dataset(table, eval_values.as_ref(), table_ref); Ok(matches!( runtime.eval_expr( filter, @@ -8996,6 +10448,7 @@ mod tests { covering_payload_column_indexes: vec![], nullable: true, unique: false, + predicate_expr: None, }; let row = vec![Value::Text("a".to_string()), Value::Null]; assert!(prepared_index_contains_null(&index, &row)); @@ -9009,6 +10462,7 @@ mod tests { covering_payload_column_indexes: vec![], nullable: false, unique: false, + predicate_expr: None, }; let key = prepared_btree_index_key(&index2, &[Value::Int64(99)]).unwrap(); assert_eq!(key, RuntimeBtreeKey::Int64(99)); @@ -9026,6 +10480,7 @@ mod tests { covering_payload_column_indexes: vec![], nullable: false, unique: false, + predicate_expr: None, }; let key = prepared_btree_index_key(&index_uuid, &[Value::Uuid(uuid)]).unwrap(); assert_eq!(key, RuntimeBtreeKey::Uuid(uuid)); @@ -9039,6 +10494,7 @@ mod tests { covering_payload_column_indexes: vec![], nullable: false, unique: false, + predicate_expr: None, }; if let RuntimeBtreeKey::Encoded(bytes) = prepared_btree_index_key(&index3, &[Value::Text("x".to_string())]).unwrap() @@ -9479,6 +10935,49 @@ mod tests { ); } + #[test] + fn apply_parent_delete_cascade_batches_child_deleted_row_tracking() { + let mut runtime = EngineRuntime::empty(1); + execute_sql(&mut runtime, "CREATE TABLE parent (id INT64 PRIMARY KEY)"); + execute_sql( + &mut runtime, + "CREATE TABLE child ( + id INT64 PRIMARY KEY, + parent_id INT64 NOT NULL REFERENCES parent(id) ON DELETE CASCADE, + label TEXT NOT NULL + )", + ); + execute_sql( + &mut runtime, + "CREATE INDEX idx_child_parent ON child(parent_id)", + ); + execute_sql(&mut runtime, "INSERT INTO parent VALUES (1), (2)"); + execute_sql( + &mut runtime, + "INSERT INTO child VALUES + (10, 1, 'drop-a'), + (11, 1, 'drop-b'), + (12, 1, 'drop-c'), + (20, 2, 'keep')", + ); + + execute_sql(&mut runtime, "DELETE FROM parent WHERE id = 1"); + + let remaining = query_sql(&mut runtime, "SELECT id FROM child ORDER BY id"); + assert_eq!(remaining.rows().len(), 1); + assert_eq!(remaining.rows()[0].values(), &[Value::Int64(20)]); + let deleted_rows = runtime + .paged_mutations + .get("child") + .expect("child mutation delta") + .deleted_rows + .clone(); + assert_eq!( + deleted_rows, + [10_i64, 11, 12].into_iter().collect::>() + ); + } + #[test] fn fk_matching_row_ids_via_index_supports_composite_keys() { let mut runtime = EngineRuntime::empty(1); @@ -11026,6 +12525,7 @@ mod dml_private_tests { covering_payload_column_indexes: vec![], nullable: true, unique: false, + predicate_expr: None, }; let row = vec![Value::Null]; assert!(prepared_index_contains_null(&index, &row)); @@ -11044,6 +12544,7 @@ mod dml_private_tests { covering_payload_column_indexes: vec![], nullable: false, unique: false, + predicate_expr: None, }; let row3 = vec![Value::Text("x".to_string())]; let key2 = prepared_btree_index_key(&idx2, &row3).unwrap(); @@ -11061,6 +12562,7 @@ mod dml_private_tests { covering_payload_column_indexes: vec![], nullable: false, unique: false, + predicate_expr: None, }; let row4 = vec![Value::Text("a".to_string()), Value::Int64(2)]; let key3 = prepared_btree_index_key(&idx3, &row4).unwrap(); diff --git a/crates/decentdb/src/exec/dml_more_tests.rs b/crates/decentdb/src/exec/dml_more_tests.rs index 7d32b59b..93fea215 100644 --- a/crates/decentdb/src/exec/dml_more_tests.rs +++ b/crates/decentdb/src/exec/dml_more_tests.rs @@ -389,6 +389,7 @@ mod tests { primary_auto_row_id_column_index: None, value_sources: vec![], required_columns: vec![], + generated_columns: vec![], foreign_keys: vec![], unique_indexes: vec![], insert_indexes: vec![], @@ -477,6 +478,7 @@ mod tests { primary_auto_row_id_column_index: None, value_sources: vec![], required_columns: vec![], + generated_columns: vec![], foreign_keys: vec![], unique_indexes: vec![], insert_indexes: vec![], @@ -565,6 +567,7 @@ mod tests { primary_auto_row_id_column_index: Some(0), value_sources: vec![], required_columns: vec![], + generated_columns: vec![], foreign_keys: vec![], unique_indexes: vec![], insert_indexes: vec![], diff --git a/crates/decentdb/src/exec/dml_unit_tests.rs b/crates/decentdb/src/exec/dml_unit_tests.rs index 6118bc30..8cb191c4 100644 --- a/crates/decentdb/src/exec/dml_unit_tests.rs +++ b/crates/decentdb/src/exec/dml_unit_tests.rs @@ -846,6 +846,7 @@ mod tests { primary_auto_row_id_column_index: None, value_sources: vec![], required_columns: vec![], + generated_columns: vec![], foreign_keys: vec![], unique_indexes: vec![], insert_indexes: vec![], diff --git a/crates/decentdb/src/exec/mod.rs b/crates/decentdb/src/exec/mod.rs index c23cfa98..daeed40e 100644 --- a/crates/decentdb/src/exec/mod.rs +++ b/crates/decentdb/src/exec/mod.rs @@ -1236,6 +1236,12 @@ impl TablePageManifest { row.row_id, &encoded_values, )?; + chunk.pointer = OverflowPointer { + head_page_id: 0, + logical_len: 0, + flags: 0, + }; + chunk.checksum = 0; chunk.row_count = chunk .row_count .checked_add(1) @@ -2651,6 +2657,89 @@ impl RuntimeBtreeKeys { Ok(values) } + pub(super) fn row_ids_for_values(&self, values: &[&Value]) -> Result> { + if values.is_empty() { + return Ok(Vec::new()); + } + let mut row_ids = Vec::new(); + match self { + Self::UniqueEncoded(keys, deleted) => { + for value in values { + let key = encode_index_key(value)?; + if let Some(row_id) = keys.get(&key) { + if !deleted.contains(row_id) { + row_ids.push(*row_id); + } + } + } + } + Self::NonUniqueEncoded(keys, deleted) => { + for value in values { + let key = encode_index_key(value)?; + if let Some(entry_row_ids) = keys.get(&key) { + row_ids.extend( + entry_row_ids + .iter() + .copied() + .filter(|row_id| !deleted.contains(row_id)), + ); + } + } + } + Self::UniqueInt64(keys, deleted) => { + for value in values { + if let Value::Int64(value) = value { + if let Some(row_id) = keys.get(value) { + if !deleted.contains(row_id) { + row_ids.push(*row_id); + } + } + } + } + } + Self::NonUniqueInt64(keys, deleted) => { + for value in values { + if let Value::Int64(value) = value { + if let Some(entry_row_ids) = keys.get(value) { + row_ids.extend( + entry_row_ids + .iter() + .copied() + .filter(|row_id| !deleted.contains(row_id)), + ); + } + } + } + } + Self::UniqueUuid(keys, deleted) => { + for value in values { + if let Value::Uuid(value) = value { + if let Some(row_id) = keys.get(value) { + if !deleted.contains(row_id) { + row_ids.push(*row_id); + } + } + } + } + } + Self::NonUniqueUuid(keys, deleted) => { + for value in values { + if let Value::Uuid(value) = value { + if let Some(entry_row_ids) = keys.get(value) { + row_ids.extend( + entry_row_ids + .iter() + .copied() + .filter(|row_id| !deleted.contains(row_id)), + ); + } + } + } + } + } + Ok(row_ids) + } + fn distinct_key_counts(&self) -> Vec<(RuntimeBtreeKey, usize)> { match self { Self::UniqueEncoded(keys, deleted) => keys @@ -4511,6 +4600,14 @@ impl EngineRuntime { &canonical_table_name, pk_index_root, )?; + let persisted_manifest = table_page_manifest_with_persisted_chunks( + manifest, + &persisted_chunks, + ); + self.replace_table_row_source( + &canonical_table_name, + TableRowSource::Paged(Arc::new(persisted_manifest)), + )?; self.cache_payload_remove(&canonical_table_name); continue; } @@ -4528,6 +4625,12 @@ impl EngineRuntime { previous_state.pk_index_root, )?; } + let persisted_manifest = + table_page_manifest_with_persisted_chunks(manifest, &persisted_chunks); + self.replace_table_row_source( + &canonical_table_name, + TableRowSource::Paged(Arc::new(persisted_manifest)), + )?; self.cache_payload_remove(&canonical_table_name); continue; } @@ -4538,6 +4641,12 @@ impl EngineRuntime { &persisted_chunks, )?; replace_table_pk_index_root(self, db, &canonical_table_name, pk_index_root)?; + let persisted_manifest = + table_page_manifest_with_persisted_chunks(manifest, &persisted_chunks); + self.replace_table_row_source( + &canonical_table_name, + TableRowSource::Paged(Arc::new(persisted_manifest)), + )?; self.cache_payload_remove(&canonical_table_name); continue; } @@ -6819,7 +6928,7 @@ impl EngineRuntime { if let Some(result) = Self::try_execute_simple_integer_series_query(query) { return Ok(result); } - if let Some(result) = self.try_execute_simple_count_query(query)? { + if let Some(result) = self.try_execute_simple_count_query(query, params)? { return Ok(result); } if let Some(result) = self.try_execute_simple_min_max_query(query)? { @@ -6947,32 +7056,89 @@ impl EngineRuntime { } } } - let mut lines = planner::plan_statement( - &Statement::Explain(explain.clone()), - &planner_catalog, - )? - .render(); - if explain.analyze { - lines.insert(0, "ANALYZE true".to_string()); - let started = Instant::now(); - let actual_rows = match explain.statement.as_ref() { - Statement::Query(query) => self - .evaluate_query(query, params, &BTreeMap::new())? - .rows - .len(), - other => { + match explain.statement.as_ref() { + Statement::Update(update) => { + if explain.analyze { + return Err(DbError::sql( + "EXPLAIN ANALYZE is not supported for UPDATE".to_string(), + )); + } + if self + .visible_view(&update.table_name, NameResolutionScope::Session) + .is_some() + { return Err(DbError::sql(format!( - "EXPLAIN ANALYZE is not supported for {other:?}" - ))) + "EXPLAIN UPDATE is not supported for view {}", + update.table_name + ))); } - }; - lines.push(format!("Actual Rows: {actual_rows}")); - lines.push(format!( - "Actual Time: {:.3} ms", - started.elapsed().as_secs_f64() * 1_000.0 - )); + + let mut lines = vec![format!("Mutation: UPDATE {}", update.table_name)]; + for (index, assignment) in update.assignments.iter().enumerate() { + lines.push(format!( + "Assignment {}: {} = {}", + index + 1, + assignment.column_name, + assignment.expr.to_sql() + )); + } + match &update.filter { + Some(filter) => lines.push(format!("Filter: {}", filter.to_sql())), + None => lines.push("Filter: ".to_string()), + } + let table = self.table_schema(&update.table_name).ok_or_else(|| { + DbError::sql(format!("unknown table {}", update.table_name)) + })?; + let candidate_rows = dml::matching_row_ids( + self, + &update.table_name, + &update.table_name, + table, + update.filter.as_ref(), + params, + )? + .len(); + lines.push(format!("Candidate rows: {candidate_rows}")); + lines.push(format!( + "Returning: {}", + if update.returning.is_empty() { + "OFF" + } else { + "ON" + } + )); + + Ok(QueryResult::with_explain(lines)) + } + _ => { + let mut lines = planner::plan_statement( + &Statement::Explain(explain.clone()), + &planner_catalog, + )? + .render(); + if explain.analyze { + lines.insert(0, "ANALYZE true".to_string()); + let started = Instant::now(); + let actual_rows = match explain.statement.as_ref() { + Statement::Query(query) => self + .evaluate_query(query, params, &BTreeMap::new())? + .rows + .len(), + other => { + return Err(DbError::sql(format!( + "EXPLAIN ANALYZE is not supported for {other:?}" + ))) + } + }; + lines.push(format!("Actual Rows: {actual_rows}")); + lines.push(format!( + "Actual Time: {:.3} ms", + started.elapsed().as_secs_f64() * 1_000.0 + )); + } + Ok(QueryResult::with_explain(lines)) + } } - Ok(QueryResult::with_explain(lines)) } other => Err(DbError::internal(format!( "read-only execution received mutating statement {other:?}" @@ -7003,8 +7169,7 @@ impl EngineRuntime { let QueryBody::Select(select) = &query.body else { return Ok(None); }; - if select.filter.is_some() - || !select.group_by.is_empty() + if !select.group_by.is_empty() || select.having.is_some() || select.distinct || !select.distinct_on.is_empty() @@ -7013,7 +7178,11 @@ impl EngineRuntime { { return Ok(None); } - let FromItem::Table { name, .. } = &select.from[0] else { + let FromItem::Table { + name, + alias: table_alias, + } = &select.from[0] + else { return Ok(None); }; if self @@ -7055,22 +7224,43 @@ impl EngineRuntime { Ok(Some(SimpleCountQueryPlan { table_name: name, + table_ref: table_alias.as_deref().unwrap_or(name), + filter: select.filter.as_ref(), column_name: alias.clone().unwrap_or_else(|| infer_expr_name(expr, 1)), })) } - fn try_execute_simple_count_query(&self, query: &Query) -> Result> { + fn try_execute_simple_count_query( + &self, + query: &Query, + params: &[Value], + ) -> Result> { let Some(plan) = self.analyze_simple_count_query(query)? else { return Ok(None); }; - let row_count = self.visible_table_row_source(plan.table_name).map_or_else( - || { - self.table_data(plan.table_name) - .map_or(0, TableData::row_count) - }, - |source| source.row_count(), - ); + let row_count = if let Some(filter) = plan.filter { + let table = self + .table_schema(plan.table_name) + .ok_or_else(|| DbError::sql(format!("unknown table {}", plan.table_name)))?; + dml::matching_row_ids( + self, + plan.table_name, + plan.table_ref, + table, + Some(filter), + params, + )? + .len() + } else { + self.visible_table_row_source(plan.table_name).map_or_else( + || { + self.table_data(plan.table_name) + .map_or(0, TableData::row_count) + }, + |source| source.row_count(), + ) + }; let row_count = i64::try_from(row_count).map_err(|_| { DbError::sql(format!( "table {} exceeds COUNT(*) row-count limits", @@ -7243,6 +7433,9 @@ impl EngineRuntime { let Some(plan) = self.analyze_simple_count_query(query)? else { return Ok(None); }; + if plan.filter.is_some() { + return Ok(None); + } if self.visible_table_is_temporary(plan.table_name) || self.visible_table_row_source(plan.table_name).is_some() || !self.has_deferred_tables() @@ -13634,7 +13827,7 @@ impl EngineRuntime { query: &Query, params: &[Value], ) -> Result> { - if query.recursive || !query.ctes.is_empty() || !query.order_by.is_empty() { + if query.recursive || !query.ctes.is_empty() { return Ok(None); } let Some(limit_expr) = query.limit.as_ref() else { @@ -13691,13 +13884,43 @@ impl EngineRuntime { }; if view_select.distinct || !view_select.distinct_on.is_empty() - || view_select.filter.is_some() || !view_select.group_by.is_empty() || view_select.having.is_some() || projection_has_aggregate_items(&view_select.projection) { return Ok(None); } + if !query.order_by.is_empty() { + return if view.temporary { + self.try_execute_ordered_view_projection_limit_select( + select, + view_select, + &view.name, + &view.column_names, + view_binding, + &query.order_by, + limit, + offset, + params, + ) + } else { + let persistent_runtime = self.persistent_resolution_runtime(); + persistent_runtime.try_execute_ordered_view_projection_limit_select( + select, + view_select, + &view.name, + &view.column_names, + view_binding, + &query.order_by, + limit, + offset, + params, + ) + }; + } + if view_select.filter.is_some() { + return Ok(None); + } let mut pushed_projection = Vec::with_capacity(select.projection.len()); for (index, item) in select.projection.iter().enumerate() { @@ -13746,6 +13969,122 @@ impl EngineRuntime { } } + #[allow(clippy::too_many_arguments)] + fn try_execute_ordered_view_projection_limit_select( + &self, + outer_select: &Select, + view_select: &Select, + view_name: &str, + view_column_names: &[String], + view_binding: &str, + order_by: &[OrderBy], + limit: usize, + offset: usize, + params: &[Value], + ) -> Result> { + if order_by.len() != 1 || order_by[0].collation.is_some() { + return Ok(None); + } + let Some(pushed_projection) = pushed_view_projection_for_outer_projection( + &outer_select.projection, + view_select, + view_name, + view_binding, + view_column_names, + ) else { + return Ok(None); + }; + + let mut join_select = view_select.clone(); + join_select.filter = None; + let Some(plan) = self.analyze_indexed_join_limit_projection_select( + &join_select, + &pushed_projection, + limit, + offset, + )? + else { + return Ok(None); + }; + + let Expr::Column { + table: order_table, + column: order_column, + } = &order_by[0].expr + else { + return Ok(None); + }; + if order_table.as_deref().is_some_and(|qualifier| { + !identifiers_equal(qualifier, view_binding) && !identifiers_equal(qualifier, view_name) + }) { + return Ok(None); + } + let Some(order_expr) = view_projection_expr_for_output_column_with_names( + &view_select.projection, + view_column_names, + order_column, + ) else { + return Ok(None); + }; + let Some((order_table_index, order_column_index)) = + indexed_join_limit_projection_column(&order_expr, &plan.tables, self) + else { + return Ok(None); + }; + if order_table_index != 0 { + return Ok(None); + } + + let root_table = plan.tables[0]; + let root_binding = root_table.alias.as_deref().unwrap_or(root_table.name); + let Some(root_schema) = self.table_schema(root_table.name) else { + return Ok(None); + }; + let Some(order_column_schema) = root_schema.columns.get(order_column_index) else { + return Ok(None); + }; + + let root_filter_columns = if let Some(filter) = view_select.filter.as_ref() { + let Some(root_columns) = indexed_join_table_eval_columns(&plan.tables[..1], self) + else { + return Ok(None); + }; + let Some(join_columns) = indexed_join_table_eval_columns(&plan.tables, self) else { + return Ok(None); + }; + let root_dataset = Dataset::with_rows(root_columns.clone(), Vec::new()); + let join_dataset = Dataset::with_rows(join_columns, Vec::new()); + if !expr_resolves_against_dataset(filter, &root_dataset) + || !expr_resolves_against_dataset(filter, &join_dataset) + { + return Ok(None); + } + Some(root_columns) + } else { + None + }; + + let Some(index) = self.ordered_view_root_btree_index( + root_table.name, + &order_column_schema.name, + view_select.filter.as_ref(), + root_binding, + )? + else { + return Ok(None); + }; + + self.execute_ordered_indexed_join_limit_projection_plan( + &plan, + view_select.filter.as_ref(), + root_filter_columns, + &index.name, + order_by[0].descending, + params, + ) + .map(Some) + } + fn try_execute_indexed_join_limit_projection_query( &self, query: &Query, @@ -14073,6 +14412,157 @@ impl EngineRuntime { Ok(indexed_join_limit_result(plan, rows)) } + fn execute_ordered_indexed_join_limit_projection_plan( + &self, + plan: &IndexedJoinLimitPlan<'_>, + root_filter: Option<&Expr>, + root_filter_columns: Option>, + order_index_name: &str, + descending: bool, + params: &[Value], + ) -> Result { + let sources = plan + .tables + .iter() + .map(|table| { + self.visible_table_row_source(table.name).ok_or_else(|| { + DbError::internal(format!("table {} row source is missing", table.name)) + }) + }) + .collect::>>()?; + let keys = plan + .steps + .iter() + .map(|step| { + let Some(index_name) = step.right_index_name.as_deref() else { + return Ok(None); + }; + let Some(RuntimeIndex::Btree { keys, .. }) = self.index(index_name) else { + return Err(DbError::internal(format!( + "index {index_name} is missing for ordered indexed join limit plan", + ))); + }; + Ok(Some(keys)) + }) + .collect::>>()?; + let Some(RuntimeIndex::Btree { + keys: order_keys, .. + }) = self.index(order_index_name) + else { + return Err(DbError::internal(format!( + "ordered index {order_index_name} is missing for ordered view limit plan", + ))); + }; + + let root_filter_dataset = + root_filter_columns.map(|columns| Dataset::with_rows(columns, Vec::new())); + let mut rows = Vec::new(); + let mut offset_remaining = plan.offset; + let mut limit_remaining = plan.limit; + let ctes = BTreeMap::new(); + + visit_runtime_btree_row_ids_in_order(order_keys, descending, |root_row_id| { + let Some(root_row) = sources[0].row_by_id(root_row_id)? else { + return Ok(false); + }; + if let (Some(filter), Some(dataset)) = (root_filter, root_filter_dataset.as_ref()) { + if !matches!( + self.eval_expr(filter, dataset, root_row.values(), params, &ctes, None)?, + Value::Bool(true) + ) { + return Ok(false); + } + } + + if plan.tables.len() == 2 { + let step0 = &plan.steps[0]; + let Some(probe_value) = root_row.values().get(step0.previous_column_index) else { + return Err(DbError::internal("join probe row is shorter than schema")); + }; + for row1_id in indexed_join_row_ids_for_value(keys[0], probe_value)? { + let Some(row1) = sources[1].row_by_id(row1_id)? else { + continue; + }; + let current = [root_row.values(), row1.values()]; + if push_indexed_join_limit_projection( + ¤t, + &plan.projections, + &mut offset_remaining, + &mut limit_remaining, + &mut rows, + ) { + return Ok(true); + } + } + } else { + let step0 = &plan.steps[0]; + let Some(probe_value0) = root_row.values().get(step0.previous_column_index) else { + return Err(DbError::internal("join probe row is shorter than schema")); + }; + for row1_id in indexed_join_row_ids_for_value(keys[0], probe_value0)? { + let Some(row1) = sources[1].row_by_id(row1_id)? else { + continue; + }; + let current01 = [root_row.values(), row1.values()]; + let step1 = &plan.steps[1]; + let Some(probe_value1) = current01 + .get(step1.previous_table_index) + .and_then(|row| row.get(step1.previous_column_index)) + else { + return Err(DbError::internal("join probe row is shorter than schema")); + }; + for row2_id in indexed_join_row_ids_for_value(keys[1], probe_value1)? { + let Some(row2) = sources[2].row_by_id(row2_id)? else { + continue; + }; + let current = [root_row.values(), row1.values(), row2.values()]; + if push_indexed_join_limit_projection( + ¤t, + &plan.projections, + &mut offset_remaining, + &mut limit_remaining, + &mut rows, + ) { + return Ok(true); + } + } + } + } + Ok(false) + })?; + + Ok(indexed_join_limit_result(plan, rows)) + } + + fn ordered_view_root_btree_index( + &self, + table_name: &str, + column_name: &str, + root_filter: Option<&Expr>, + root_binding: &str, + ) -> Result> { + let mut full_index = None; + for index in self.catalog.indexes.values() { + if !single_plain_btree_index_matches_column(index, table_name, column_name) { + continue; + } + let Some(predicate_sql) = index.predicate_sql.as_deref() else { + if full_index.is_none() { + full_index = Some(index); + } + continue; + }; + let Some(root_filter) = root_filter else { + continue; + }; + let predicate = crate::sql::parser::parse_expression_sql(predicate_sql)?; + if filter_contains_partial_index_predicate(root_filter, &predicate, root_binding) { + return Ok(Some(index)); + } + } + Ok(full_index) + } + fn execute_indexed_join_projection_rows( &self, plan: &IndexedJoinLimitPlan<'_>, @@ -27725,6 +28215,8 @@ struct ActiveColumnMask { struct SimpleCountQueryPlan<'a> { table_name: &'a str, + table_ref: &'a str, + filter: Option<&'a Expr>, column_name: String, } @@ -27834,6 +28326,92 @@ fn compare_window_sorted_rows( left.row_index.cmp(&right.row_index) } +fn simple_window_column_positions( + dataset: &Dataset, + expressions: &[Expr], +) -> Result>> { + let mut positions = Vec::with_capacity(expressions.len()); + for expr in expressions { + let Expr::Column { table, column } = expr else { + return Ok(None); + }; + positions.push(resolve_dataset_column_position( + dataset, + table.as_deref(), + column, + )?); + } + Ok(Some(positions)) +} + +fn simple_window_order_column_positions( + dataset: &Dataset, + order_by: &[OrderBy], +) -> Result>> { + let mut positions = Vec::with_capacity(order_by.len()); + for order in order_by { + if order.collation.is_some() { + return Ok(None); + } + let Expr::Column { table, column } = &order.expr else { + return Ok(None); + }; + positions.push(resolve_dataset_column_position( + dataset, + table.as_deref(), + column, + )?); + } + Ok(Some(positions)) +} + +fn resolve_dataset_column_position( + dataset: &Dataset, + table: Option<&str>, + column: &str, +) -> Result { + let mut matched_index = None; + for (index, binding) in dataset.columns.iter().enumerate() { + let visible_match = table.is_some() || !binding.hidden; + if !visible_match || !identifiers_equal(&binding.name, column) { + continue; + } + if table.is_some_and(|table| { + !binding + .table + .as_deref() + .is_some_and(|binding_table| identifiers_equal(binding_table, table)) + }) { + continue; + } + if matched_index.replace(index).is_some() { + return Err(DbError::sql(format!("ambiguous column reference {column}"))); + } + } + matched_index.ok_or_else(|| DbError::sql(format!("unknown column {column}"))) +} + +fn values_from_positions(row: &[Value], positions: &[usize]) -> Result> { + positions + .iter() + .map(|position| { + row.get(*position) + .cloned() + .ok_or_else(|| DbError::internal("window row is shorter than its bindings")) + }) + .collect() +} + +fn window_key_from_positions(row: &[Value], positions: &[usize]) -> Result> { + if let [position] = positions { + let value = row + .get(*position) + .ok_or_else(|| DbError::internal("window row is shorter than its bindings"))?; + return row_identity(std::slice::from_ref(value)); + } + row_identity(&values_from_positions(row, positions)?) +} + fn rows_preceding_current_frame(frame: Option<&crate::sql::ast::WindowFrame>) -> Option { let frame = frame?; if frame.unit != crate::sql::ast::WindowFrameUnit::Rows { @@ -28925,6 +29503,23 @@ pub(super) fn compute_index_key_with_predicate( } return Ok(Some(RuntimeBtreeKey::Encoded(encode_index_key(&value)?))); } + if let Some(positions) = plain_index_column_positions(index, table) { + if positions.len() > 1 { + let values = positions + .iter() + .map(|position| { + row_values + .get(*position) + .cloned() + .ok_or_else(|| DbError::internal("row is shorter than table schema")) + }) + .collect::>>()?; + if index.unique && values.iter().any(|value| matches!(value, Value::Null)) { + return Ok(None); + } + return Ok(Some(RuntimeBtreeKey::Encoded(Row::new(values).encode()?))); + } + } let values = compute_index_values(runtime, index, table, row_values)?; if index.unique && values.iter().any(|value| matches!(value, Value::Null)) { return Ok(None); @@ -29169,6 +29764,9 @@ pub(super) fn row_satisfies_index_predicate_with_expr( &expr_owned } }; + if let Some(result) = simple_stored_column_eq_literal_predicate(table, row_values, expr)? { + return Ok(result); + } let row_materialized = if generated_columns_are_stored(table) { Cow::Borrowed(row_values) } else { @@ -29223,6 +29821,61 @@ pub(crate) fn row_satisfies_expression( )) } +fn simple_stored_column_eq_literal_predicate( + table: &TableSchema, + row_values: &[Value], + expr: &Expr, +) -> Result> { + if !generated_columns_are_stored(table) { + return Ok(None); + } + let Expr::Binary { + left, + op: BinaryOp::Eq, + right, + } = expr + else { + return Ok(None); + }; + let Some((table_qualifier, column_name, literal_value)) = + simple_column_literal_eq(left, right).or_else(|| simple_column_literal_eq(right, left)) + else { + return Ok(None); + }; + if table_qualifier.is_some_and(|qualifier| !identifiers_equal(qualifier, &table.name)) { + return Ok(None); + } + let Some(position) = column_position(table, column_name) else { + return Ok(None); + }; + let Some(column) = table.columns.get(position) else { + return Ok(None); + }; + let Some(row_value) = row_values.get(position) else { + return Ok(None); + }; + if matches!(row_value, Value::Null) || matches!(literal_value, Value::Null) { + return Ok(Some(false)); + } + let literal_value = constraints::coerce_column_value(column, literal_value.clone())?; + Ok(Some( + compare_values(row_value, &literal_value)? == std::cmp::Ordering::Equal, + )) +} + +fn simple_column_literal_eq<'a>( + left: &'a Expr, + right: &'a Expr, +) -> Option<(Option<&'a str>, &'a str, &'a Value)> { + let Expr::Column { table, column } = left else { + return None; + }; + let Expr::Literal(value) = right else { + return None; + }; + Some((table.as_deref(), column.as_str(), value)) +} + pub(super) fn table_row_dataset(table: &TableSchema, row: &[Value], table_name: &str) -> Dataset { Dataset::with_rows( table @@ -31604,6 +32257,30 @@ fn persisted_chunk_from_current( } } +fn table_page_manifest_chunk_visible_row_count(chunk: &TablePageManifestChunk) -> Result { + let base_physical = read_table_payload_row_count_from_bytes(&chunk.payload)?; + let overlay_physical = chunk + .overlay_payload + .as_ref() + .map(|payload| read_table_payload_row_count_from_bytes(payload)) + .transpose()? + .unwrap_or(0); + Ok(base_physical + .saturating_sub(chunk.tombstoned_row_ids.len()) + .saturating_add(overlay_physical)) +} + +fn table_page_manifest_with_persisted_chunks( + current: &TablePageManifest, + persisted_chunks: &[TablePageManifestChunk], +) -> TablePageManifest { + TablePageManifest { + chunks: Arc::new(persisted_chunks.to_vec()), + rows: Arc::clone(¤t.rows), + tombstoned_row_ids: Arc::clone(¤t.tombstoned_row_ids), + } +} + fn try_append_only_paged_table_from_manifest( store: &mut S, previous_state: PersistedTableState, @@ -32289,6 +32966,7 @@ fn rewrite_paged_table_from_manifest( }; let mut previous_payloads = None; let mut reused_previous = vec![false; previous_chunks.len()]; + let mut replaced_overlay_pointers = Vec::new(); let mut new_chunks = Vec::with_capacity(manifest.chunks.len()); let mut persisted_chunks = Vec::with_capacity(manifest.chunks.len()); @@ -32310,6 +32988,67 @@ fn rewrite_paged_table_from_manifest( new_chunks.push(chunk_state.clone()); continue; } + if chunk_state.pointer.head_page_id != 0 + && chunk_state.pointer == current_chunk.pointer + && chunk_state.checksum == current_chunk.checksum + { + reused_previous[current_index] = true; + let current_overlay_checksum = current_chunk + .overlay_payload + .as_ref() + .map(|payload| crc32c_parts(&[payload.as_slice()])); + let (overlay_pointer, overlay_checksum) = match ( + ¤t_chunk.overlay_payload, + current_chunk.overlay_pointer, + current_chunk.overlay_checksum, + ) { + (Some(_), Some(pointer), Some(checksum)) + if Some(pointer) == chunk_state.overlay_pointer + && Some(checksum) == chunk_state.overlay_checksum => + { + (Some(pointer), Some(checksum)) + } + (Some(overlay_payload), _, _) => { + let pointer = write_overflow( + store, + overlay_payload.as_slice(), + CompressionMode::Never, + )?; + let checksum = current_overlay_checksum.ok_or_else(|| { + DbError::internal("overlay checksum missing for paged table chunk") + })?; + (Some(pointer), Some(checksum)) + } + (None, _, _) => (None, None), + }; + if let Some(previous_overlay_pointer) = chunk_state.overlay_pointer { + if Some(previous_overlay_pointer) != overlay_pointer + && previous_overlay_pointer.head_page_id != 0 + { + replaced_overlay_pointers.push(previous_overlay_pointer); + } + } + let visible = table_page_manifest_chunk_visible_row_count(current_chunk)?; + new_chunks.push(PersistedTableChunkState { + pointer: chunk_state.pointer, + checksum: chunk_state.checksum, + row_count: visible, + tombstoned_row_ids: current_chunk.tombstoned_row_ids.iter().copied().collect(), + overlay_pointer, + overlay_checksum, + }); + persisted_chunks.push(TablePageManifestChunk { + pointer: chunk_state.pointer, + checksum: chunk_state.checksum, + row_count: visible, + payload: Arc::clone(¤t_chunk.payload), + tombstoned_row_ids: Arc::clone(¤t_chunk.tombstoned_row_ids), + overlay_pointer, + overlay_checksum, + overlay_payload: current_chunk.overlay_payload.clone(), + }); + continue; + } } if previous_payloads.is_none() { @@ -32386,15 +33125,7 @@ fn rewrite_paged_table_from_manifest( } else { (None, None) }; - let base_physical = read_table_payload_row_count_from_bytes(¤t_chunk.payload)?; - let overlay_physical = current_chunk - .overlay_payload - .as_ref() - .map(|p| read_table_payload_row_count_from_bytes(p).unwrap_or(0)) - .unwrap_or(0); - let visible = base_physical - .saturating_sub(current_chunk.tombstoned_row_ids.len()) - .saturating_add(overlay_physical); + let visible = table_page_manifest_chunk_visible_row_count(current_chunk)?; new_chunks.push(PersistedTableChunkState { pointer, checksum, @@ -32442,6 +33173,11 @@ fn rewrite_paged_table_from_manifest( } } } + for overlay_pointer in replaced_overlay_pointers { + if overlay_pointer.head_page_id != 0 { + free_overflow(store, overlay_pointer.head_page_id)?; + } + } Ok(( PersistedTableState { @@ -37250,6 +37986,372 @@ fn indexed_join_limit_projection_column( matched } +fn pushed_view_projection_for_outer_projection( + outer_projection: &[SelectItem], + view_select: &Select, + view_name: &str, + view_binding: &str, + view_column_names: &[String], +) -> Option> { + let mut pushed = Vec::new(); + for item in outer_projection { + match item { + SelectItem::Wildcard => { + append_all_view_projection_items(&mut pushed, view_select, view_column_names)?; + } + SelectItem::QualifiedWildcard(qualifier) + if identifiers_equal(qualifier, view_binding) + || identifiers_equal(qualifier, view_name) => + { + append_all_view_projection_items(&mut pushed, view_select, view_column_names)?; + } + SelectItem::QualifiedWildcard(_) => return None, + SelectItem::Expr { expr, alias } => { + let Expr::Column { table, column } = expr else { + return None; + }; + if table.as_deref().is_some_and(|qualifier| { + !identifiers_equal(qualifier, view_binding) + && !identifiers_equal(qualifier, view_name) + }) { + return None; + } + let view_expr = view_projection_expr_for_output_column_with_names( + &view_select.projection, + view_column_names, + column, + )?; + pushed.push(SelectItem::Expr { + expr: view_expr, + alias: Some(alias.clone().unwrap_or_else(|| infer_expr_name(expr, 1))), + }); + } + } + } + Some(pushed) +} + +fn append_all_view_projection_items( + pushed: &mut Vec, + view_select: &Select, + view_column_names: &[String], +) -> Option<()> { + for (index, item) in view_select.projection.iter().enumerate() { + let SelectItem::Expr { expr, .. } = item else { + return None; + }; + pushed.push(SelectItem::Expr { + expr: expr.clone(), + alias: Some(view_output_column_name( + &view_select.projection, + view_column_names, + index, + )?), + }); + } + Some(()) +} + +fn view_projection_expr_for_output_column_with_names( + items: &[SelectItem], + view_column_names: &[String], + column: &str, +) -> Option { + for (index, item) in items.iter().enumerate() { + if identifiers_equal( + &view_output_column_name(items, view_column_names, index)?, + column, + ) { + let SelectItem::Expr { expr, .. } = item else { + return None; + }; + return Some(expr.clone()); + } + } + None +} + +fn view_output_column_name( + items: &[SelectItem], + view_column_names: &[String], + index: usize, +) -> Option { + if let Some(name) = view_column_names.get(index) { + return Some(name.clone()); + } + let SelectItem::Expr { expr, alias } = items.get(index)? else { + return None; + }; + Some( + alias + .clone() + .unwrap_or_else(|| infer_expr_name(expr, index + 1)), + ) +} + +fn indexed_join_table_eval_columns( + tables: &[IndexedJoinLimitTablePlan<'_>], + runtime: &EngineRuntime, +) -> Option> { + let mut columns = Vec::new(); + for table in tables { + let schema = runtime.table_schema(table.name)?; + let binding_name = table.alias.as_deref().unwrap_or(table.name); + columns.extend(schema.columns.iter().map(|column| { + ColumnBinding::visible_source( + Some(binding_name.to_string()), + Some(schema.name.clone()), + column.name.clone(), + ) + })); + } + Some(columns) +} + +fn single_plain_btree_index_matches_column( + index: &IndexSchema, + table_name: &str, + column_name: &str, +) -> bool { + identifiers_equal(&index.table_name, table_name) + && index.fresh + && index.kind == IndexKind::Btree + && index.columns.len() == 1 + && index.columns[0].expression_sql.is_none() + && index.columns[0] + .column_name + .as_deref() + .is_some_and(|index_column| identifiers_equal(index_column, column_name)) +} + +fn filter_contains_partial_index_predicate( + filter: &Expr, + predicate: &Expr, + root_binding: &str, +) -> bool { + match filter { + Expr::Binary { + left, + op: BinaryOp::And, + right, + } => { + filter_contains_partial_index_predicate(left, predicate, root_binding) + || filter_contains_partial_index_predicate(right, predicate, root_binding) + } + _ => partial_index_predicate_expr_matches(filter, predicate, root_binding), + } +} + +fn partial_index_predicate_expr_matches(left: &Expr, right: &Expr, root_binding: &str) -> bool { + match (left, right) { + ( + Expr::Column { + table: left_table, + column: left_column, + }, + Expr::Column { + table: right_table, + column: right_column, + }, + ) => { + identifiers_equal(left_column, right_column) + && partial_index_predicate_qualifier_matches(left_table.as_deref(), root_binding) + && partial_index_predicate_qualifier_matches(right_table.as_deref(), root_binding) + } + (Expr::Literal(left), Expr::Literal(right)) => left == right, + ( + Expr::Binary { + left: left_left, + op: left_op, + right: left_right, + }, + Expr::Binary { + left: right_left, + op: right_op, + right: right_right, + }, + ) if left_op == right_op => { + let direct = partial_index_predicate_expr_matches(left_left, right_left, root_binding) + && partial_index_predicate_expr_matches(left_right, right_right, root_binding); + direct + || binary_op_is_commutative_for_partial_predicate(*left_op) + && partial_index_predicate_expr_matches(left_left, right_right, root_binding) + && partial_index_predicate_expr_matches(left_right, right_left, root_binding) + } + ( + Expr::Unary { + op: left_op, + expr: left_expr, + }, + Expr::Unary { + op: right_op, + expr: right_expr, + }, + ) if left_op == right_op => { + partial_index_predicate_expr_matches(left_expr, right_expr, root_binding) + } + ( + Expr::Cast { + expr: left_expr, + target_type: left_type, + }, + Expr::Cast { + expr: right_expr, + target_type: right_type, + }, + ) if left_type == right_type => { + partial_index_predicate_expr_matches(left_expr, right_expr, root_binding) + } + ( + Expr::IsNull { + expr: left_expr, + negated: left_not, + }, + Expr::IsNull { + expr: right_expr, + negated: right_not, + }, + ) if left_not == right_not => { + partial_index_predicate_expr_matches(left_expr, right_expr, root_binding) + } + _ => left == right, + } +} + +fn partial_index_predicate_qualifier_matches(qualifier: Option<&str>, root_binding: &str) -> bool { + qualifier.is_none_or(|qualifier| identifiers_equal(qualifier, root_binding)) +} + +fn binary_op_is_commutative_for_partial_predicate(op: BinaryOp) -> bool { + matches!( + op, + BinaryOp::Eq + | BinaryOp::NotEq + | BinaryOp::And + | BinaryOp::Or + | BinaryOp::Add + | BinaryOp::Mul + | BinaryOp::IsDistinctFrom + | BinaryOp::IsNotDistinctFrom + ) +} + +fn visit_runtime_btree_row_ids_in_order( + keys: &RuntimeBtreeKeys, + descending: bool, + mut visitor: F, +) -> Result +where + F: FnMut(i64) -> Result, +{ + match keys { + RuntimeBtreeKeys::UniqueEncoded(entries, deleted) => { + if descending { + for row_id in entries.values().rev() { + if !deleted.contains(row_id) && visitor(*row_id)? { + return Ok(true); + } + } + } else { + for row_id in entries.values() { + if !deleted.contains(row_id) && visitor(*row_id)? { + return Ok(true); + } + } + } + } + RuntimeBtreeKeys::NonUniqueEncoded(entries, deleted) => { + if descending { + for row_ids in entries.values().rev() { + for row_id in row_ids { + if !deleted.contains(row_id) && visitor(*row_id)? { + return Ok(true); + } + } + } + } else { + for row_ids in entries.values() { + for row_id in row_ids { + if !deleted.contains(row_id) && visitor(*row_id)? { + return Ok(true); + } + } + } + } + } + RuntimeBtreeKeys::UniqueUuid(entries, deleted) => { + if descending { + for row_id in entries.values().rev() { + if !deleted.contains(row_id) && visitor(*row_id)? { + return Ok(true); + } + } + } else { + for row_id in entries.values() { + if !deleted.contains(row_id) && visitor(*row_id)? { + return Ok(true); + } + } + } + } + RuntimeBtreeKeys::NonUniqueUuid(entries, deleted) => { + if descending { + for row_ids in entries.values().rev() { + for row_id in row_ids { + if !deleted.contains(row_id) && visitor(*row_id)? { + return Ok(true); + } + } + } + } else { + for row_ids in entries.values() { + for row_id in row_ids { + if !deleted.contains(row_id) && visitor(*row_id)? { + return Ok(true); + } + } + } + } + } + RuntimeBtreeKeys::UniqueInt64(entries, deleted) => { + let mut ordered = entries + .iter() + .filter(|(_, row_id)| !deleted.contains(row_id)) + .map(|(key, row_id)| (*key, *row_id)) + .collect::>(); + ordered.sort_unstable_by_key(|(key, _)| *key); + if descending { + ordered.reverse(); + } + for (_, row_id) in ordered { + if visitor(row_id)? { + return Ok(true); + } + } + } + RuntimeBtreeKeys::NonUniqueInt64(entries, deleted) => { + let mut ordered = entries + .iter() + .map(|(key, row_ids)| (*key, row_ids.as_slice())) + .collect::>(); + ordered.sort_unstable_by_key(|(key, _)| *key); + if descending { + ordered.reverse(); + } + for (_, row_ids) in ordered { + let mut row_ids = row_ids.to_vec(); + row_ids.sort_unstable(); + for row_id in row_ids { + if !deleted.contains(&row_id) && visitor(row_id)? { + return Ok(true); + } + } + } + } + } + Ok(false) +} + fn indexed_join_limit_rows_for_value( source: VisibleTableRowSource<'_>, keys: Option<&RuntimeBtreeKeys>, @@ -42968,6 +44070,19 @@ impl EngineRuntime { window_values[peer_index] = Some(lag_values); continue; } + if let Some(peer_index) = Self::find_row_number_rank_peer(items, item_index) { + let (row_number_values, rank_values) = self + .compute_row_number_rank_values( + dataset, + partition_by, + order_by, + params, + ctes, + )?; + window_values[item_index] = Some(row_number_values); + window_values[peer_index] = Some(rank_values); + continue; + } window_values[item_index] = Some(self.compute_row_number_values( dataset, partition_by, @@ -43028,6 +44143,22 @@ impl EngineRuntime { continue; } } + if name.eq_ignore_ascii_case("rank") { + if let Some(peer_index) = Self::find_rank_row_number_peer(items, item_index) + { + let (row_number_values, rank_values) = self + .compute_row_number_rank_values( + dataset, + partition_by, + order_by, + params, + ctes, + )?; + window_values[item_index] = Some(rank_values); + window_values[peer_index] = Some(row_number_values); + continue; + } + } window_values[item_index] = Some(self.compute_window_function_values( dataset, name, @@ -43132,6 +44263,91 @@ impl EngineRuntime { }) } + fn find_row_number_rank_peer(items: &[SelectItem], item_index: usize) -> Option { + let SelectItem::Expr { + expr: + Expr::RowNumber { + partition_by, + order_by, + frame, + }, + .. + } = items.get(item_index)? + else { + return None; + }; + items.iter().enumerate().find_map(|(peer_index, item)| { + if peer_index == item_index { + return None; + } + let SelectItem::Expr { + expr: + Expr::WindowFunction { + name, + args, + partition_by: peer_partition_by, + order_by: peer_order_by, + frame: peer_frame, + distinct, + star, + }, + .. + } = item + else { + return None; + }; + (!*distinct + && !*star + && args.is_empty() + && name.eq_ignore_ascii_case("rank") + && peer_partition_by == partition_by + && peer_order_by == order_by + && peer_frame == frame) + .then_some(peer_index) + }) + } + + fn find_rank_row_number_peer(items: &[SelectItem], item_index: usize) -> Option { + let SelectItem::Expr { + expr: + Expr::WindowFunction { + name, + args, + partition_by, + order_by, + frame, + distinct, + star, + }, + .. + } = items.get(item_index)? + else { + return None; + }; + if *distinct || *star || !args.is_empty() || !name.eq_ignore_ascii_case("rank") { + return None; + } + items.iter().enumerate().find_map(|(peer_index, item)| { + if peer_index == item_index { + return None; + } + let SelectItem::Expr { + expr: + Expr::RowNumber { + partition_by: peer_partition_by, + order_by: peer_order_by, + frame: peer_frame, + }, + .. + } = item + else { + return None; + }; + (peer_partition_by == partition_by && peer_order_by == order_by && peer_frame == frame) + .then_some(peer_index) + }) + } + fn find_rank_dense_rank_peer(items: &[SelectItem], item_index: usize) -> Option { let SelectItem::Expr { expr: @@ -43198,9 +44414,12 @@ impl EngineRuntime { ctes: &BTreeMap, ) -> Result, Vec>> { let mut partitions = BTreeMap::, Vec>::new(); + let simple_positions = simple_window_column_positions(dataset, partition_by)?; for (row_index, row) in dataset.rows.iter().enumerate() { let key = if partition_by.is_empty() { vec![0] + } else if let Some(positions) = simple_positions.as_ref() { + window_key_from_positions(row, positions)? } else { let values = partition_by .iter() @@ -43222,16 +44441,21 @@ impl EngineRuntime { ctes: &BTreeMap, ) -> Result> { let mut sorted = Vec::with_capacity(indices.len()); + let simple_order_positions = simple_window_order_column_positions(dataset, order_by)?; for row_index in indices { let row = dataset .rows .get(row_index) .map(Vec::as_slice) .ok_or_else(|| DbError::internal("window row index is invalid"))?; - let order_keys = order_by - .iter() - .map(|order| self.eval_expr(&order.expr, dataset, row, params, ctes, None)) - .collect::>>()?; + let order_keys = if let Some(positions) = simple_order_positions.as_ref() { + values_from_positions(row, positions)? + } else { + order_by + .iter() + .map(|order| self.eval_expr(&order.expr, dataset, row, params, ctes, None)) + .collect::>>()? + }; sorted.push(WindowSortedRow { row_index, order_keys, @@ -43330,6 +44554,37 @@ impl EngineRuntime { Ok((rank_results, dense_rank_results)) } + fn compute_row_number_rank_values( + &self, + dataset: &Dataset, + partition_by: &[Expr], + order_by: &[crate::sql::ast::OrderBy], + params: &[Value], + ctes: &BTreeMap, + ) -> Result<(Vec, Vec)> { + let partitions = self.window_partitions(dataset, partition_by, params, ctes)?; + + let mut row_number_results = vec![Value::Null; dataset.rows.len()]; + let mut rank_results = vec![Value::Null; dataset.rows.len()]; + for indices in partitions.into_values() { + let sorted = self.sorted_window_partition(dataset, indices, order_by, params, ctes)?; + let mut current_rank = 1_i64; + for (ordinal, sorted_row) in sorted.iter().enumerate() { + if ordinal > 0 + && !window_order_keys_equal( + &sorted[ordinal - 1].order_keys, + &sorted_row.order_keys, + )? + { + current_rank = (ordinal + 1) as i64; + } + row_number_results[sorted_row.row_index] = Value::Int64((ordinal + 1) as i64); + rank_results[sorted_row.row_index] = Value::Int64(current_rank); + } + } + Ok((row_number_results, rank_results)) + } + fn compute_row_number_lag_values( &self, context: WindowEvalContext<'_>, diff --git a/crates/decentdb/src/exec/tests.rs b/crates/decentdb/src/exec/tests.rs index 207a85cc..0bf23f4e 100644 --- a/crates/decentdb/src/exec/tests.rs +++ b/crates/decentdb/src/exec/tests.rs @@ -18,7 +18,7 @@ use super::{ encode_manifest_payload, encode_paged_table_chunks, encode_paged_table_chunks_from_rows, encode_runtime_payload, encode_table_payload, like_match, persist_paged_table, read_deferred_row_by_id_from_table_payload, read_table_page_manifest_from_state, - rewrite_paged_table_from_resident, simple_trigram_lookup, + rewrite_paged_table_from_manifest, rewrite_paged_table_from_resident, simple_trigram_lookup, try_append_only_paged_table_from_manifest, ColumnBinding, Dataset, DbTxnPageStore, EngineRuntime, OverflowPointer, PersistedTableState, QueryRow, RuntimeBtreeKeys, RuntimeIndex, SimpleOrderByPlan, StoredRow, TableData, TablePageManifest, TablePageManifestChunk, @@ -4341,6 +4341,141 @@ fn sparse_paged_row_deletions_do_not_decode_changed_base_chunk() { )); } +#[test] +fn rewrite_paged_manifest_update_reuses_changed_base_chunk_pointer() { + let body = "x".repeat(2048); + let initial = TableData::from_rows( + (1_i64..=96_i64) + .map(|row_id| StoredRow { + row_id, + values: vec![Value::Int64(row_id), Value::Text(body.clone())], + }) + .collect(), + ); + let mut store = InMemoryPageStore::new(PAGE_SIZE); + let initial_chunks = + encode_paged_table_chunks(&initial, PAGE_SIZE).expect("encode initial chunks"); + let initial_state = persist_paged_table( + &mut store, + PersistedTableState::default(), + &initial_chunks, + initial.rows.len(), + ) + .expect("persist initial paged table"); + let initial_manifest_payload = + crate::record::overflow::read_overflow(&store, initial_state.pointer) + .expect("read initial manifest"); + let initial_manifest = decode_paged_table_manifest_payload(&initial_manifest_payload) + .expect("decode initial manifest"); + let page_manifest = + read_table_page_manifest_from_state(&store, initial_state).expect("read page manifest"); + let changed_chunk_index = page_manifest + .chunk_index_for_row_id(6) + .expect("changed row chunk"); + let changed_chunk_pointer = initial_manifest.chunks[changed_chunk_index].pointer; + + let mut row_changes = BTreeMap::new(); + row_changes.insert( + 6, + Some(vec![Value::Int64(6), Value::Text("updated".to_string())]), + ); + let updated_manifest = + apply_paged_row_changes_to_manifest(&page_manifest, &row_changes).expect("update row"); + let (updated_state, persisted_chunks) = + rewrite_paged_table_from_manifest(&mut store, initial_state, &updated_manifest) + .expect("rewrite paged table from manifest"); + let updated_manifest_payload = + crate::record::overflow::read_overflow(&store, updated_state.pointer) + .expect("read updated manifest"); + let updated_persisted = decode_paged_table_manifest_payload(&updated_manifest_payload) + .expect("decode updated manifest"); + let updated_page_manifest = + TablePageManifest::from_chunks(persisted_chunks).expect("read persisted chunks"); + let row = updated_page_manifest + .row_by_id(6) + .expect("read updated row") + .expect("updated row should exist"); + + assert_eq!( + updated_persisted.chunks[changed_chunk_index].pointer, changed_chunk_pointer, + "overlay update should preserve the changed chunk base payload pointer" + ); + assert!(updated_persisted.chunks[changed_chunk_index] + .tombstoned_row_ids + .contains(&6)); + assert!(updated_persisted.chunks[changed_chunk_index] + .overlay_pointer + .is_some()); + assert_eq!( + row.values(), + &[Value::Int64(6), Value::Text("updated".to_string())] + ); +} + +#[test] +fn rewrite_paged_manifest_delete_reuses_changed_base_chunk_pointer() { + let body = "x".repeat(2048); + let initial = TableData::from_rows( + (1_i64..=96_i64) + .map(|row_id| StoredRow { + row_id, + values: vec![Value::Int64(row_id), Value::Text(body.clone())], + }) + .collect(), + ); + let mut store = InMemoryPageStore::new(PAGE_SIZE); + let initial_chunks = + encode_paged_table_chunks(&initial, PAGE_SIZE).expect("encode initial chunks"); + let initial_state = persist_paged_table( + &mut store, + PersistedTableState::default(), + &initial_chunks, + initial.rows.len(), + ) + .expect("persist initial paged table"); + let initial_manifest_payload = + crate::record::overflow::read_overflow(&store, initial_state.pointer) + .expect("read initial manifest"); + let initial_manifest = decode_paged_table_manifest_payload(&initial_manifest_payload) + .expect("decode initial manifest"); + let page_manifest = + read_table_page_manifest_from_state(&store, initial_state).expect("read page manifest"); + let changed_chunk_index = page_manifest + .chunk_index_for_row_id(6) + .expect("deleted row chunk"); + let changed_chunk_pointer = initial_manifest.chunks[changed_chunk_index].pointer; + let deleted_row_ids = [6_i64].into_iter().collect::>(); + + let updated_manifest = apply_paged_row_deletions_to_manifest(&page_manifest, &deleted_row_ids) + .expect("delete row"); + let (updated_state, persisted_chunks) = + rewrite_paged_table_from_manifest(&mut store, initial_state, &updated_manifest) + .expect("rewrite paged table from manifest"); + let updated_manifest_payload = + crate::record::overflow::read_overflow(&store, updated_state.pointer) + .expect("read updated manifest"); + let updated_persisted = decode_paged_table_manifest_payload(&updated_manifest_payload) + .expect("decode updated manifest"); + let updated_page_manifest = + TablePageManifest::from_chunks(persisted_chunks).expect("read persisted chunks"); + + assert_eq!( + updated_persisted.chunks[changed_chunk_index].pointer, changed_chunk_pointer, + "delete should preserve the changed chunk base payload pointer" + ); + assert!(updated_persisted.chunks[changed_chunk_index] + .tombstoned_row_ids + .contains(&6)); + assert!(updated_persisted.chunks[changed_chunk_index] + .overlay_pointer + .is_none()); + assert_eq!(updated_page_manifest.row_count(), 95); + assert!(updated_page_manifest + .row_by_id(6) + .expect("read deleted row") + .is_none()); +} + #[test] fn persist_to_db_resident_paged_row_updates_preserves_untouched_chunk_pointers() { let body = "x".repeat(2048); @@ -4446,6 +4581,96 @@ fn persist_to_db_resident_paged_row_updates_preserves_untouched_chunk_pointers() ); } +#[test] +fn persist_to_db_paged_row_delete_succeeds_after_materialized_snapshot() { + let body = "x".repeat(2048); + let config = DbConfig { + paged_row_storage: true, + defer_table_materialization: false, + ..DbConfig::default() + }; + let db = Db::open_or_create(":memory:", config).expect("open db"); + db.execute("CREATE TABLE docs (id INT64 PRIMARY KEY, body TEXT)") + .expect("create table"); + for row_id in 1_i64..=96_i64 { + db.execute(&format!( + "INSERT INTO docs (id, body) VALUES ({row_id}, '{}')", + body + )) + .expect("insert row"); + } + + let mut runtime = db.debug_engine_snapshot().expect("snapshot runtime"); + let initial_state = runtime.persisted_tables["docs"]; + let store = DbTxnPageStore { db: &db }; + db.begin_write().expect("begin write transaction"); + let initial_manifest_payload = + crate::record::overflow::read_overflow(&store, initial_state.pointer) + .expect("read manifest payload"); + let initial_manifest = decode_paged_table_manifest_payload(&initial_manifest_payload) + .expect("decode manifest payload"); + let initial_page_manifest = + read_table_page_manifest_from_state(&store, initial_state).expect("read manifest"); + db.commit().expect("commit write transaction"); + assert!( + initial_manifest.chunks.len() > 2, + "expected multiple chunks to observe pointer preservation" + ); + let changed_chunk_index = initial_page_manifest.rows[5].chunk_index as usize; + let untouched_pointers = initial_manifest + .chunks + .iter() + .enumerate() + .filter_map(|(index, chunk)| (index != changed_chunk_index).then_some(chunk.pointer)) + .collect::>(); + + let statement = parse_sql_statement("DELETE FROM docs WHERE id = 6").expect("parse delete"); + let crate::sql::ast::Statement::Delete(delete) = statement else { + panic!("expected delete statement"); + }; + let prepared = runtime + .prepare_simple_delete(&delete) + .expect("prepare delete") + .expect("expected prepared delete"); + runtime + .execute_prepared_simple_delete(&prepared, &[], PAGE_SIZE) + .expect("execute prepared delete"); + + db.begin_write().expect("begin write txn"); + runtime.persist_to_db(&db).expect("persist runtime"); + db.commit().expect("commit write txn"); + + let rewritten_state = runtime.persisted_tables["docs"]; + db.begin_write().expect("begin write transaction"); + let rewritten_manifest_payload = + crate::record::overflow::read_overflow(&store, rewritten_state.pointer) + .expect("read manifest payload"); + let rewritten_manifest = decode_paged_table_manifest_payload(&rewritten_manifest_payload) + .expect("decode manifest payload"); + let rewritten_page_manifest = + read_table_page_manifest_from_state(&store, rewritten_state).expect("read manifest"); + db.commit().expect("commit write transaction"); + let preserved_untouched = rewritten_manifest + .chunks + .iter() + .filter_map(|chunk| { + untouched_pointers + .contains(&chunk.pointer) + .then_some(chunk.pointer) + }) + .collect::>(); + + assert_eq!( + preserved_untouched, untouched_pointers, + "materialized snapshot delete should preserve untouched chunk pointers" + ); + assert_eq!(rewritten_page_manifest.row_count(), 95); + assert!(rewritten_page_manifest + .row_by_id(6) + .expect("read deleted row") + .is_none()); +} + #[test] fn prepared_simple_update_multiple_assignments_updates_indexed_queries() { let mut runtime = EngineRuntime::empty(1); diff --git a/crates/decentdb/src/planner/mod.rs b/crates/decentdb/src/planner/mod.rs index 1625f75c..8fa43ca0 100644 --- a/crates/decentdb/src/planner/mod.rs +++ b/crates/decentdb/src/planner/mod.rs @@ -2261,7 +2261,6 @@ fn maybe_expand_view(name: &str, catalog: &CatalogState) -> Result Result CatalogState { + let mut catalog = catalog_with_artist_table(); + catalog.views.insert( + "v_filtered_artist".to_string(), + ViewSchema { + name: "v_filtered_artist".to_string(), + temporary: false, + sql_text: + "SELECT Id, NameNormalized FROM Artist WHERE NameNormalized = 'MOTLEYCRUE'" + .to_string(), + column_names: vec!["Id".to_string(), "NameNormalized".to_string()], + dependencies: vec!["Artist".to_string()], + }, + ); + catalog + } + fn single_table_select(filter: Expr) -> Select { Select { distinct: false, @@ -2522,6 +2538,28 @@ mod tests { ); } + #[test] + fn explain_plan_expands_filtered_view_for_ordered_limit_query() { + let catalog = catalog_with_filtered_artist_view(); + let statement = parse_sql_statement("SELECT Id FROM v_filtered_artist ORDER BY Id LIMIT 5") + .expect("parse"); + + let lines = plan_statement(&statement, &catalog).expect("plan").render(); + + assert!( + lines.iter().any(|line| line.contains( + "ExpandedView(name=v_filtered_artist, pushedFilter=true, pushedProjection=true, pushedLimit=false" + )), + "expected filtered view expansion with projection pushdown, got: {lines:?}" + ); + assert!( + lines + .iter() + .any(|line| line.contains("predicate=(namenormalized = 'MOTLEYCRUE')")), + "expected expanded filtered view to retain the view predicate in an indexed or filtered path, got: {lines:?}" + ); + } + fn catalog_with_two_table_join(indexed: bool, with_stats: bool) -> CatalogState { let mut catalog = CatalogState::empty(0); catalog.tables.insert( diff --git a/crates/decentdb/tests/sql_crm_perf_regression_tests.rs b/crates/decentdb/tests/sql_crm_perf_regression_tests.rs new file mode 100644 index 00000000..b32b1eb5 --- /dev/null +++ b/crates/decentdb/tests/sql_crm_perf_regression_tests.rs @@ -0,0 +1,912 @@ +//! CRM-shaped correctness regressions for planned performance work. +//! +//! These tests intentionally avoid asserting specific plan nodes or timing. +//! They pin observable behavior for the safe Phase 4/5/6/8 scopes in +//! `design/2026-06-30_PERF_PLAN.md`. + +use decentdb::{Db, DbConfig, QueryResult, Value}; + +fn mem_db() -> Db { + Db::open_or_create(":memory:", DbConfig::default()).unwrap() +} + +fn exec(db: &Db, sql: &str) -> QueryResult { + db.execute(sql).unwrap() +} + +fn rows(result: &QueryResult) -> Vec> { + result + .rows() + .iter() + .map(|row| row.values().to_vec()) + .collect() +} + +fn assert_float_close(value: &Value, expected: f64) { + match value { + Value::Float64(actual) => assert!( + (actual - expected).abs() < 0.000_001, + "expected {expected}, got {actual}" + ), + other => panic!("expected FLOAT64 {expected}, got {other:?}"), + } +} + +fn setup_p5_selectivity_update_dataset(db: &Db) { + exec( + db, + "CREATE TABLE crm_p5_selectivity_invoices ( + id INT64 PRIMARY KEY, + company_id INT64 NOT NULL, + total FLOAT64 NOT NULL, + paid BOOL NOT NULL + )", + ); + exec( + db, + "CREATE INDEX crm_p5_selectivity_paid_total_idx + ON crm_p5_selectivity_invoices(paid, total)", + ); + + let insert = db + .prepare( + "INSERT INTO crm_p5_selectivity_invoices (id, company_id, total, paid) + VALUES ($1, $2, $3, $4)", + ) + .unwrap(); + for id in 1_i64..=100_i64 { + insert + .execute(&[ + Value::Int64(id), + Value::Int64(10), + Value::Float64(id as f64), + Value::Bool(false), + ]) + .unwrap(); + } +} + +#[test] +fn crm_invoice_item_generated_stored_insert_reuses_prepared_statement() { + let db = mem_db(); + exec( + &db, + "CREATE TABLE crm_p4_invoices ( + id INT64 PRIMARY KEY, + company_id INT64 NOT NULL + )", + ); + exec( + &db, + "CREATE TABLE crm_p4_invoice_items ( + id INT64 PRIMARY KEY, + invoice_id INT64 NOT NULL REFERENCES crm_p4_invoices(id), + sku TEXT NOT NULL, + quantity INT64 NOT NULL, + unit_price FLOAT64 NOT NULL, + line_total FLOAT64 GENERATED ALWAYS AS (quantity * unit_price) STORED + )", + ); + exec( + &db, + "CREATE INDEX crm_p4_items_invoice_idx ON crm_p4_invoice_items(invoice_id)", + ); + exec(&db, "INSERT INTO crm_p4_invoices VALUES (10, 1), (11, 1)"); + + let insert_item = db + .prepare( + "INSERT INTO crm_p4_invoice_items + (id, invoice_id, sku, quantity, unit_price) + VALUES ($1, $2, $3, $4, $5)", + ) + .unwrap(); + for (id, invoice_id, sku, quantity, unit_price) in [ + (100, 10, "setup", 2, 19.50), + (101, 10, "seat", 5, 7.25), + (102, 11, "support", 1, 99.00), + ] { + insert_item + .execute(&[ + Value::Int64(id), + Value::Int64(invoice_id), + Value::Text(sku.to_string()), + Value::Int64(quantity), + Value::Float64(unit_price), + ]) + .unwrap(); + } + + let stored_totals = exec( + &db, + "SELECT id, line_total FROM crm_p4_invoice_items ORDER BY id", + ); + let stored_rows = rows(&stored_totals); + assert_eq!(stored_rows[0][0], Value::Int64(100)); + assert_float_close(&stored_rows[0][1], 39.0); + assert_eq!(stored_rows[1][0], Value::Int64(101)); + assert_float_close(&stored_rows[1][1], 36.25); + assert_eq!(stored_rows[2][0], Value::Int64(102)); + assert_float_close(&stored_rows[2][1], 99.0); + + let explicit_generated = db + .execute( + "INSERT INTO crm_p4_invoice_items + (id, invoice_id, sku, quantity, unit_price, line_total) + VALUES (103, 11, 'bad', 1, 1.0, 1.0)", + ) + .unwrap_err() + .to_string(); + assert!( + explicit_generated.contains("cannot INSERT into generated column"), + "unexpected error: {explicit_generated}" + ); +} + +#[test] +fn crm_invoice_prepared_insert_maintains_partial_covering_index() { + let db = mem_db(); + exec( + &db, + "CREATE TABLE crm_p4_batch_users ( + id INT64 PRIMARY KEY, + full_name TEXT NOT NULL, + email TEXT NOT NULL + )", + ); + exec( + &db, + "CREATE TABLE crm_p4_batch_invoices ( + id INT64 PRIMARY KEY, + user_id INT64 NOT NULL REFERENCES crm_p4_batch_users(id), + invoice_number TEXT UNIQUE NOT NULL, + due_at TEXT NOT NULL, + total FLOAT64 NOT NULL, + paid BOOL NOT NULL + )", + ); + exec( + &db, + "CREATE INDEX crm_p4_batch_unpaid_due_idx + ON crm_p4_batch_invoices(due_at) + INCLUDE (user_id, invoice_number, total) + WHERE paid = FALSE", + ); + exec( + &db, + "INSERT INTO crm_p4_batch_users VALUES + (1, 'Ada Lovelace', 'ada@example.com'), + (2, 'Grace Hopper', 'grace@example.com')", + ); + exec( + &db, + "CREATE VIEW crm_p4_batch_unpaid AS + SELECT i.id, i.invoice_number, u.full_name, u.email, i.total, i.due_at + FROM crm_p4_batch_invoices AS i + JOIN crm_p4_batch_users AS u ON u.id = i.user_id + WHERE i.paid = FALSE", + ); + + let insert_invoice = db + .prepare( + "INSERT INTO crm_p4_batch_invoices + (id, user_id, invoice_number, due_at, total, paid) + VALUES ($1, $2, $3, $4, $5, $6)", + ) + .unwrap(); + for (id, user_id, number, due_at, total, paid) in [ + (1, 1, "INV-001", "2026-07-01", 10.0, false), + (2, 1, "INV-002", "2026-08-01", 20.0, true), + (3, 2, "INV-003", "2026-07-10", 30.0, false), + (4, 2, "INV-004", "2026-07-20", 40.0, false), + ] { + insert_invoice + .execute(&[ + Value::Int64(id), + Value::Int64(user_id), + Value::Text(number.to_string()), + Value::Text(due_at.to_string()), + Value::Float64(total), + Value::Bool(paid), + ]) + .unwrap(); + } + + let newest = exec( + &db, + "SELECT invoice_number + FROM crm_p4_batch_unpaid + ORDER BY due_at DESC + LIMIT 3", + ); + assert_eq!( + rows(&newest) + .into_iter() + .map(|row| row[0].clone()) + .collect::>(), + vec![ + Value::Text("INV-004".to_string()), + Value::Text("INV-003".to_string()), + Value::Text("INV-001".to_string()), + ] + ); +} + +#[test] +fn crm_indexed_paid_total_update_is_correct_for_repeat_no_rows_and_rollback() { + let db = mem_db(); + exec( + &db, + "CREATE TABLE crm_p5_invoices ( + id INT64 PRIMARY KEY, + company_id INT64 NOT NULL, + total FLOAT64 NOT NULL, + paid BOOL NOT NULL + )", + ); + exec( + &db, + "CREATE INDEX crm_p5_paid_total_idx ON crm_p5_invoices(paid, total)", + ); + exec( + &db, + "INSERT INTO crm_p5_invoices VALUES + (1, 10, 25.00, FALSE), + (2, 10, 75.00, FALSE), + (3, 20, 125.00, FALSE), + (4, 20, 45.00, TRUE), + (5, 30, 60.00, FALSE)", + ); + + let select_candidates = db + .prepare( + "SELECT id + FROM crm_p5_invoices + WHERE paid = FALSE AND total < $1 + ORDER BY id", + ) + .unwrap(); + let pre_update = rows(&select_candidates.execute(&[Value::Float64(100.0)]).unwrap()); + assert_eq!( + pre_update, + vec![ + vec![Value::Int64(1)], + vec![Value::Int64(2)], + vec![Value::Int64(5)], + ] + ); + + let mark_paid = db + .prepare("UPDATE crm_p5_invoices SET paid = TRUE WHERE paid = FALSE AND total < $1") + .unwrap(); + let first = mark_paid.execute(&[Value::Float64(100.0)]).unwrap(); + assert_eq!(first.affected_rows(), pre_update.len() as u64); + + let second = mark_paid.execute(&[Value::Float64(100.0)]).unwrap(); + assert_eq!(second.affected_rows(), 0); + + let post_update = rows(&select_candidates.execute(&[Value::Float64(100.0)]).unwrap()); + assert!(post_update.is_empty()); + + let count_paid_under_limit = db + .prepare( + "SELECT COUNT(*) + FROM crm_p5_invoices AS i + WHERE i.paid = TRUE AND i.total < $1", + ) + .unwrap(); + let count_result = count_paid_under_limit + .execute(&[Value::Float64(100.0)]) + .unwrap(); + assert_eq!(rows(&count_result), vec![vec![Value::Int64(4)]]); + + let paid_state = exec(&db, "SELECT id, paid FROM crm_p5_invoices ORDER BY id"); + assert_eq!( + rows(&paid_state), + vec![ + vec![Value::Int64(1), Value::Bool(true)], + vec![Value::Int64(2), Value::Bool(true)], + vec![Value::Int64(3), Value::Bool(false)], + vec![Value::Int64(4), Value::Bool(true)], + vec![Value::Int64(5), Value::Bool(true)], + ] + ); + + exec(&db, "BEGIN"); + let rollback_update = mark_paid.execute(&[Value::Float64(200.0)]).unwrap(); + assert_eq!(rollback_update.affected_rows(), 1); + exec(&db, "ROLLBACK"); + + let unpaid_after_rollback = exec( + &db, + "SELECT id FROM crm_p5_invoices WHERE paid = FALSE ORDER BY id", + ); + assert_eq!(rows(&unpaid_after_rollback), vec![vec![Value::Int64(3)]]); +} + +#[test] +fn crm_indexed_paid_total_update_rechecks_residual_predicates() { + let db = mem_db(); + exec( + &db, + "CREATE TABLE crm_p5_residual_invoices ( + id INT64 PRIMARY KEY, + company_id INT64 NOT NULL, + total FLOAT64 NOT NULL, + paid BOOL NOT NULL + )", + ); + exec( + &db, + "CREATE INDEX crm_p5_residual_paid_total_idx + ON crm_p5_residual_invoices(paid, total)", + ); + exec( + &db, + "INSERT INTO crm_p5_residual_invoices VALUES + (1, 10, 25.00, FALSE), + (2, 10, 75.00, FALSE), + (3, 20, 50.00, FALSE), + (4, 20, 125.00, FALSE), + (5, 10, 45.00, TRUE)", + ); + + let update = db + .prepare( + "UPDATE crm_p5_residual_invoices + SET paid = TRUE + WHERE paid = FALSE AND total < $1 AND company_id = $2", + ) + .unwrap(); + let result = update + .execute(&[Value::Float64(100.0), Value::Int64(10)]) + .unwrap(); + assert_eq!(result.affected_rows(), 2); + + let paid_state = exec( + &db, + "SELECT id, paid FROM crm_p5_residual_invoices ORDER BY id", + ); + assert_eq!( + rows(&paid_state), + vec![ + vec![Value::Int64(1), Value::Bool(true)], + vec![Value::Int64(2), Value::Bool(true)], + vec![Value::Int64(3), Value::Bool(false)], + vec![Value::Int64(4), Value::Bool(false)], + vec![Value::Int64(5), Value::Bool(true)], + ] + ); +} + +#[test] +fn crm_indexed_paid_total_update_selectivity_matrix() { + let cases = [ + (1.0, 0_usize), + (2.0, 1_usize), + (11.0, 10_usize), + (51.0, 50_usize), + ]; + + for &(max_total, expected_count) in &cases { + let db = mem_db(); + setup_p5_selectivity_update_dataset(&db); + + let select_candidates = db + .prepare( + "SELECT id + FROM crm_p5_selectivity_invoices + WHERE paid = FALSE AND total < $1 + ORDER BY id", + ) + .unwrap(); + let update = db + .prepare( + "UPDATE crm_p5_selectivity_invoices + SET paid = TRUE + WHERE paid = FALSE AND total < $1", + ) + .unwrap(); + + let pre_update = rows( + &select_candidates + .execute(&[Value::Float64(max_total)]) + .unwrap(), + ); + let expected_ids: Vec> = (1_i64..=(expected_count as i64)) + .map(|id| vec![Value::Int64(id)]) + .collect(); + assert_eq!( + pre_update, expected_ids, + "select baseline should match configured selectivity for total < {max_total}" + ); + + let first = update.execute(&[Value::Float64(max_total)]).unwrap(); + assert_eq!( + first.affected_rows(), + expected_count as u64, + "first update should affect configured row count for total < {max_total}" + ); + + let second = update.execute(&[Value::Float64(max_total)]).unwrap(); + assert_eq!( + second.affected_rows(), + 0, + "repeat update should affect zero rows for total < {max_total}" + ); + } +} + +#[test] +fn crm_indexed_paid_total_delete_rechecks_residual_predicates() { + let db = mem_db(); + exec( + &db, + "CREATE TABLE crm_p5_residual_delete_invoices ( + id INT64 PRIMARY KEY, + company_id INT64 NOT NULL, + total FLOAT64 NOT NULL, + paid BOOL NOT NULL + )", + ); + exec( + &db, + "CREATE INDEX crm_p5_residual_delete_paid_total_idx + ON crm_p5_residual_delete_invoices(paid, total)", + ); + exec( + &db, + "INSERT INTO crm_p5_residual_delete_invoices VALUES + (1, 10, 25.00, FALSE), + (2, 10, 75.00, FALSE), + (3, 20, 50.00, FALSE), + (4, 20, 125.00, FALSE), + (5, 10, 45.00, TRUE)", + ); + + let delete = db + .prepare( + "DELETE FROM crm_p5_residual_delete_invoices + WHERE paid = FALSE AND total < $1 AND company_id = $2", + ) + .unwrap(); + let result = delete + .execute(&[Value::Float64(100.0), Value::Int64(10)]) + .unwrap(); + assert_eq!(result.affected_rows(), 2); + + let remaining = exec( + &db, + "SELECT id FROM crm_p5_residual_delete_invoices ORDER BY id", + ); + assert_eq!( + rows(&remaining), + vec![ + vec![Value::Int64(3)], + vec![Value::Int64(4)], + vec![Value::Int64(5)], + ] + ); +} + +#[test] +fn crm_prepared_insert_with_virtual_generated_not_null_validates_materialized_value() { + let db = mem_db(); + exec( + &db, + "CREATE TABLE crm_p4_virtual_generated ( + id INT64 PRIMARY KEY, + quantity INT64 NOT NULL, + doubled INT64 GENERATED ALWAYS AS (quantity * 2) VIRTUAL NOT NULL + )", + ); + + let insert = db + .prepare("INSERT INTO crm_p4_virtual_generated (id, quantity) VALUES ($1, $2)") + .unwrap(); + let inserted = insert + .execute(&[Value::Int64(1), Value::Int64(21)]) + .unwrap(); + assert_eq!(inserted.affected_rows(), 1); + + let projected = exec( + &db, + "SELECT id, quantity, doubled FROM crm_p4_virtual_generated", + ); + assert_eq!( + rows(&projected), + vec![vec![Value::Int64(1), Value::Int64(21), Value::Int64(42),]] + ); +} + +#[test] +fn crm_unpaid_invoices_view_ordered_limit_returns_earliest_unpaid_rows() { + let db = mem_db(); + exec( + &db, + "CREATE TABLE crm_p6_companies ( + id INT64 PRIMARY KEY, + name TEXT NOT NULL + )", + ); + exec( + &db, + "CREATE TABLE crm_p6_invoices ( + id INT64 PRIMARY KEY, + company_id INT64 NOT NULL REFERENCES crm_p6_companies(id), + due_at TEXT NOT NULL, + total FLOAT64 NOT NULL, + paid BOOL NOT NULL + )", + ); + exec( + &db, + "CREATE INDEX crm_p6_unpaid_due_idx + ON crm_p6_invoices(due_at) + WHERE paid = FALSE", + ); + exec( + &db, + "INSERT INTO crm_p6_companies VALUES (1, 'Acme'), (2, 'Globex')", + ); + exec( + &db, + "INSERT INTO crm_p6_invoices VALUES + (1, 1, '2026-07-10', 50.00, FALSE), + (2, 1, '2026-07-01', 10.00, TRUE), + (3, 2, '2026-07-03', 30.00, FALSE), + (4, 2, '2026-07-02', 20.00, FALSE), + (5, 1, '2026-07-04', 40.00, FALSE)", + ); + exec( + &db, + "CREATE VIEW v_unpaid_invoices AS + SELECT i.id, c.name AS company_name, i.due_at, i.total + FROM crm_p6_invoices AS i + JOIN crm_p6_companies AS c ON c.id = i.company_id + WHERE i.paid = FALSE", + ); + + let earliest = exec( + &db, + "SELECT id, company_name, due_at, total + FROM v_unpaid_invoices + ORDER BY due_at, id + LIMIT 3", + ); + assert_eq!( + rows(&earliest), + vec![ + vec![ + Value::Int64(4), + Value::Text("Globex".to_string()), + Value::Text("2026-07-02".to_string()), + Value::Float64(20.0), + ], + vec![ + Value::Int64(3), + Value::Text("Globex".to_string()), + Value::Text("2026-07-03".to_string()), + Value::Float64(30.0), + ], + vec![ + Value::Int64(5), + Value::Text("Acme".to_string()), + Value::Text("2026-07-04".to_string()), + Value::Float64(40.0), + ], + ] + ); +} + +#[test] +fn crm_unpaid_invoices_view_ordered_desc_limit_tracks_partial_index_writes() { + let db = mem_db(); + exec( + &db, + "CREATE TABLE crm_p6_users ( + id INTEGER PRIMARY KEY, + full_name TEXT NOT NULL, + email TEXT NOT NULL + )", + ); + exec( + &db, + "CREATE TABLE crm_p6_desc_invoices ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES crm_p6_users(id), + invoice_number TEXT NOT NULL, + due_at TEXT NOT NULL, + total FLOAT64 NOT NULL, + paid BOOL NOT NULL + )", + ); + exec( + &db, + "CREATE INDEX crm_p6_desc_unpaid_due_idx + ON crm_p6_desc_invoices(due_at) + INCLUDE (user_id, invoice_number, total) + WHERE paid = FALSE", + ); + exec( + &db, + "INSERT INTO crm_p6_users VALUES + (1, 'Ada Lovelace', 'ada@example.com'), + (2, 'Grace Hopper', 'grace@example.com')", + ); + exec( + &db, + "INSERT INTO crm_p6_desc_invoices VALUES + (1, 1, 'INV-001', '2026-07-01', 10.00, FALSE), + (2, 1, 'INV-002', '2026-08-01', 20.00, TRUE), + (3, 2, 'INV-003', '2026-07-10', 30.00, FALSE), + (4, 2, 'INV-004', '2026-07-20', 40.00, FALSE), + (5, 1, 'INV-005', '2026-07-15', 50.00, FALSE)", + ); + exec( + &db, + "CREATE VIEW v_unpaid_invoices_desc AS + SELECT i.id, i.invoice_number, u.full_name, u.email, i.total, i.due_at + FROM crm_p6_desc_invoices AS i + JOIN crm_p6_users AS u ON u.id = i.user_id + WHERE i.paid = FALSE", + ); + + let newest = exec( + &db, + "SELECT * + FROM v_unpaid_invoices_desc + ORDER BY due_at DESC + LIMIT 3", + ); + assert_eq!( + rows(&newest) + .into_iter() + .map(|row| row[0].clone()) + .collect::>(), + vec![Value::Int64(4), Value::Int64(5), Value::Int64(3)] + ); + + exec( + &db, + "UPDATE crm_p6_desc_invoices SET paid = TRUE WHERE id = 4", + ); + exec( + &db, + "INSERT INTO crm_p6_desc_invoices VALUES + (6, 1, 'INV-006', '2026-07-25', 60.00, FALSE)", + ); + + let after_writes = exec( + &db, + "SELECT * + FROM v_unpaid_invoices_desc + ORDER BY due_at DESC + LIMIT 3", + ); + assert_eq!( + rows(&after_writes) + .into_iter() + .map(|row| row[0].clone()) + .collect::>(), + vec![Value::Int64(6), Value::Int64(5), Value::Int64(3)] + ); +} + +#[test] +fn crm_cascade_delete_with_fk_indexes_removes_company_graph_and_rolls_back() { + let db = mem_db(); + exec( + &db, + "CREATE TABLE crm_p8_companies ( + id INT64 PRIMARY KEY, + name TEXT NOT NULL + )", + ); + exec( + &db, + "CREATE TABLE crm_p8_users ( + id INT64 PRIMARY KEY, + company_id INT64 NOT NULL REFERENCES crm_p8_companies(id) ON DELETE CASCADE, + email TEXT NOT NULL + )", + ); + exec( + &db, + "CREATE TABLE crm_p8_addresses ( + id INT64 PRIMARY KEY, + company_id INT64 NOT NULL REFERENCES crm_p8_companies(id) ON DELETE CASCADE, + city TEXT NOT NULL + )", + ); + exec( + &db, + "CREATE TABLE crm_p8_invoices ( + id INT64 PRIMARY KEY, + company_id INT64 NOT NULL REFERENCES crm_p8_companies(id) ON DELETE CASCADE, + user_id INT64 NOT NULL REFERENCES crm_p8_users(id) ON DELETE CASCADE, + total FLOAT64 NOT NULL + )", + ); + exec( + &db, + "CREATE TABLE crm_p8_invoice_items ( + id INT64 PRIMARY KEY, + invoice_id INT64 NOT NULL REFERENCES crm_p8_invoices(id) ON DELETE CASCADE, + amount FLOAT64 NOT NULL + )", + ); + exec( + &db, + "CREATE INDEX crm_p8_users_company_idx ON crm_p8_users(company_id)", + ); + exec( + &db, + "CREATE INDEX crm_p8_addresses_company_idx ON crm_p8_addresses(company_id)", + ); + exec( + &db, + "CREATE INDEX crm_p8_invoices_company_idx ON crm_p8_invoices(company_id)", + ); + exec( + &db, + "CREATE INDEX crm_p8_invoices_user_idx ON crm_p8_invoices(user_id)", + ); + exec( + &db, + "CREATE INDEX crm_p8_invoices_low_total_idx + ON crm_p8_invoices(total) + WHERE total < 45", + ); + exec( + &db, + "CREATE INDEX crm_p8_items_invoice_idx ON crm_p8_invoice_items(invoice_id)", + ); + + exec( + &db, + "INSERT INTO crm_p8_companies VALUES (1, 'Acme'), (2, 'Globex')", + ); + exec( + &db, + "INSERT INTO crm_p8_users VALUES + (10, 1, 'a1@example.com'), + (11, 1, 'a2@example.com'), + (20, 2, 'g1@example.com')", + ); + exec( + &db, + "INSERT INTO crm_p8_addresses VALUES + (100, 1, 'Austin'), + (101, 1, 'Dallas'), + (200, 2, 'Chicago')", + ); + exec( + &db, + "INSERT INTO crm_p8_invoices VALUES + (1000, 1, 10, 30.00), + (1001, 1, 11, 40.00), + (2000, 2, 20, 50.00)", + ); + exec( + &db, + "INSERT INTO crm_p8_invoice_items VALUES + (1, 1000, 10.00), + (2, 1000, 20.00), + (3, 1001, 40.00), + (4, 2000, 50.00)", + ); + + exec(&db, "BEGIN"); + let rollback_delete = exec(&db, "DELETE FROM crm_p8_companies WHERE id = 1"); + assert_eq!(rollback_delete.affected_rows(), 1); + exec(&db, "ROLLBACK"); + let counts_after_rollback = exec( + &db, + "SELECT + (SELECT COUNT(*) FROM crm_p8_companies), + (SELECT COUNT(*) FROM crm_p8_users), + (SELECT COUNT(*) FROM crm_p8_addresses), + (SELECT COUNT(*) FROM crm_p8_invoices), + (SELECT COUNT(*) FROM crm_p8_invoice_items)", + ); + assert_eq!( + rows(&counts_after_rollback), + vec![vec![ + Value::Int64(2), + Value::Int64(3), + Value::Int64(3), + Value::Int64(3), + Value::Int64(4), + ]] + ); + let rollback_address_lookup = exec( + &db, + "SELECT id FROM crm_p8_addresses WHERE company_id = 1 ORDER BY id", + ); + assert_eq!( + rows(&rollback_address_lookup), + vec![vec![Value::Int64(100)], vec![Value::Int64(101)]] + ); + let rollback_item_lookup = exec( + &db, + "SELECT id FROM crm_p8_invoice_items WHERE invoice_id = 1000 ORDER BY id", + ); + assert_eq!( + rows(&rollback_item_lookup), + vec![vec![Value::Int64(1)], vec![Value::Int64(2)]] + ); + let rollback_partial_index_lookup = exec( + &db, + "SELECT id FROM crm_p8_invoices WHERE total < 45 ORDER BY id", + ); + assert_eq!( + rows(&rollback_partial_index_lookup), + vec![vec![Value::Int64(1000)], vec![Value::Int64(1001)]] + ); + + let committed_delete = exec(&db, "DELETE FROM crm_p8_companies WHERE id = 1"); + assert_eq!(committed_delete.affected_rows(), 1); + + let remaining_counts = exec( + &db, + "SELECT + (SELECT COUNT(*) FROM crm_p8_companies), + (SELECT COUNT(*) FROM crm_p8_users), + (SELECT COUNT(*) FROM crm_p8_addresses), + (SELECT COUNT(*) FROM crm_p8_invoices), + (SELECT COUNT(*) FROM crm_p8_invoice_items)", + ); + assert_eq!( + rows(&remaining_counts), + vec![vec![ + Value::Int64(1), + Value::Int64(1), + Value::Int64(1), + Value::Int64(1), + Value::Int64(1), + ]] + ); + let deleted_address_lookup = exec( + &db, + "SELECT id FROM crm_p8_addresses WHERE company_id = 1 ORDER BY id", + ); + assert_eq!(rows(&deleted_address_lookup), Vec::>::new()); + let deleted_item_lookup = exec( + &db, + "SELECT id FROM crm_p8_invoice_items WHERE invoice_id = 1000 ORDER BY id", + ); + assert_eq!(rows(&deleted_item_lookup), Vec::>::new()); + let remaining_item_lookup = exec( + &db, + "SELECT id FROM crm_p8_invoice_items WHERE invoice_id = 2000 ORDER BY id", + ); + assert_eq!(rows(&remaining_item_lookup), vec![vec![Value::Int64(4)]]); + let deleted_partial_index_lookup = exec( + &db, + "SELECT id FROM crm_p8_invoices WHERE total < 45 ORDER BY id", + ); + assert_eq!( + rows(&deleted_partial_index_lookup), + Vec::>::new() + ); + + let survivors = exec( + &db, + "SELECT c.id, u.id, a.id, i.id, ii.id + FROM crm_p8_companies AS c + JOIN crm_p8_users AS u ON u.company_id = c.id + JOIN crm_p8_addresses AS a ON a.company_id = c.id + JOIN crm_p8_invoices AS i ON i.company_id = c.id + JOIN crm_p8_invoice_items AS ii ON ii.invoice_id = i.id", + ); + assert_eq!( + rows(&survivors), + vec![vec![ + Value::Int64(2), + Value::Int64(20), + Value::Int64(200), + Value::Int64(2000), + Value::Int64(4), + ]] + ); +} diff --git a/crates/decentdb/tests/sql_transactions_prepared_tests.rs b/crates/decentdb/tests/sql_transactions_prepared_tests.rs index c548c2e0..0a89258c 100644 --- a/crates/decentdb/tests/sql_transactions_prepared_tests.rs +++ b/crates/decentdb/tests/sql_transactions_prepared_tests.rs @@ -399,7 +399,36 @@ fn explain_update() { exec(&db, "CREATE TABLE eu (id INT PRIMARY KEY, val INT)"); exec(&db, "INSERT INTO eu VALUES (1, 10)"); let r = exec(&db, "EXPLAIN UPDATE eu SET val = 20 WHERE id = 1"); - assert!(!r.explain_lines().is_empty()); + let lines = format!("{:?}", r.explain_lines()); + assert!(lines.contains("Mutation: UPDATE eu")); + assert!(lines.contains("Assignment 1: val = 20")); + assert!(lines.contains("Candidate rows: 1")); + assert!(lines.contains("Returning: OFF")); +} + +#[test] +fn explain_update_without_filter_counts_all_rows() { + let db = mem_db(); + exec(&db, "CREATE TABLE eu (id INT PRIMARY KEY, val INT)"); + exec(&db, "INSERT INTO eu VALUES (1, 10), (2, 20)"); + let r = exec(&db, "EXPLAIN UPDATE eu SET val = 20"); + let lines = format!("{:?}", r.explain_lines()); + assert!(lines.contains("Mutation: UPDATE eu")); + assert!(lines.contains("Filter: ")); + assert!(lines.contains("Candidate rows: 2")); + assert!(lines.contains("Returning: OFF")); +} + +#[test] +fn explain_analyze_update_is_not_supported() { + let db = mem_db(); + exec(&db, "CREATE TABLE eu (id INT PRIMARY KEY, val INT)"); + exec(&db, "INSERT INTO eu VALUES (1, 10)"); + let err = exec_err(&db, "EXPLAIN ANALYZE UPDATE eu SET val = 20 WHERE id = 1"); + assert!( + err.contains("EXPLAIN ANALYZE is not supported for UPDATE"), + "unexpected error: {err}" + ); } #[test] diff --git a/crates/decentdb/tests/sql_window_functions_tests.rs b/crates/decentdb/tests/sql_window_functions_tests.rs index 5d8fa313..9d2ecc28 100644 --- a/crates/decentdb/tests/sql_window_functions_tests.rs +++ b/crates/decentdb/tests/sql_window_functions_tests.rs @@ -534,6 +534,41 @@ fn window_rank_dense_rank() { assert_eq!(v[2][2], Value::Int64(2)); // dense_rank } +#[test] +fn window_row_number_and_rank_share_window_spec() { + let db = mem_db(); + db.execute("CREATE TABLE t(user_id INT64, invoice TEXT, total FLOAT64)") + .unwrap(); + db.execute( + "INSERT INTO t VALUES + (1, 'a', 50.0), + (1, 'b', 75.0), + (1, 'c', 75.0), + (2, 'd', 10.0), + (2, 'e', 20.0)", + ) + .unwrap(); + + let r = db + .execute( + "SELECT user_id, invoice, + ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY total DESC) AS rn, + RANK() OVER (PARTITION BY user_id ORDER BY total DESC) AS rnk + FROM t + ORDER BY user_id, invoice", + ) + .unwrap(); + let v = rows(&r); + assert_eq!(v[0][2], Value::Int64(3)); + assert_eq!(v[0][3], Value::Int64(3)); + assert_eq!(v[1][3], Value::Int64(1)); + assert_eq!(v[2][3], Value::Int64(1)); + assert_eq!(v[3][2], Value::Int64(2)); + assert_eq!(v[3][3], Value::Int64(2)); + assert_eq!(v[4][2], Value::Int64(1)); + assert_eq!(v[4][3], Value::Int64(1)); +} + #[test] fn window_row_number_basic() { let db = mem_db(); diff --git a/design/2026-06-30_PERF_PLAN.md b/design/2026-06-30_PERF_PLAN.md new file mode 100644 index 00000000..05aac351 --- /dev/null +++ b/design/2026-06-30_PERF_PLAN.md @@ -0,0 +1,1155 @@ +# 2026-06-30 Performance Plan: DecentDB + .NET CRM Benchmark Versus SQLite + +**Date:** 2026-06-30 +**Status:** Draft phased implementation plan +**Audience:** Core engine maintainers, .NET binding maintainers, planner and +executor maintainers, storage/WAL maintainers, benchmark maintainers, +documentation authors, coding agents + +This document turns the 2026-06-30 `.NET` DecentDB-vs-SQLite CRM benchmark +results into a concrete phased plan. The tactical target is intentionally +specific: + +> Make DecentDB plus the official .NET bindings beat SQLite on every measured +> metric in the CRM benchmark app at +> `/home/steven/src/scratch/decentdb-vs-sqlite-net/DecentDbSqliteBenchmark`, +> without weakening correctness, durability semantics, or general engine +> architecture. + +This is not a claim that a single benchmark proves product-wide performance. +It is a focused compatibility and performance program that should expose +binding overhead, write-path overhead, planner gaps, view execution gaps, +mutation path gaps, and cascade-delete overhead that also matter to real .NET +embedded applications. + +--- + +## 1. Design Inputs + +- `design/PRD.md`: DecentDB must preserve durable ACID writes, fast reads, and + stable ergonomic multi-language integrations. Performance must not come at + the cost of correctness. +- `design/SPEC.md`: compatibility anchor for subsystem boundaries and + historical module organization. +- `design/TESTING_STRATEGY.md`: deterministic workloads, layered regression + tests, crash testing, and binding integration validation. +- `design/BENCHMARKING_GUIDE.md`: benchmark fairness, deterministic datasets, + durability modes, release artifacts, and machine-readable results. +- `design/adr/0014-performance-targets.md`: point lookup, FK join, substring + search, bulk-load, and recovery targets. +- `design/adr/0017-bulk-load-api-design.md` and + `design/adr/0027-bulk-load-api.md`: bulk loading is an accepted performance + surface and should be reused rather than reinvented. +- `design/adr/0037-group-commit-wal-batching.md` and + `design/adr/0162-engine-owned-write-queue-strict-group-commit.md`: existing + WAL batching and write-queue contracts. +- `design/adr/0039-dotnet-c-api-design.md` through + `design/adr/0046-dotnet-connection-string-design.md`: .NET binding and + connection string contracts. +- `design/adr/0050-explain-statement.md`: `EXPLAIN` diagnostics. +- `design/adr/0081-foreign-key-on-delete-actions-v0.md`: foreign-key cascade + semantics. +- `design/adr/0082-partial-indexes-v0.md`, + `design/adr/0100-partial-index-query-planner-exclusion.md`, and covering + index ADRs: partial/covering index eligibility and planner use. +- `design/adr/0112-cost-based-optimizer-with-stats.md`: accepted direction for + statistics-driven planning. +- `design/adr/0145-paged-table-row-source.md`, + `design/adr/0184-default-fast-planner-and-runtime-contract.md`, and + `design/adr/0190-query-plan-cache-scope-key-and-lifecycle.md` through + `design/adr/0194-query-plan-cache-prepared-plan-reuse.md`: existing fast + runtime, paged row source, and plan cache foundations. +- `design/WIN_PERFORMANCE_IMPROVEMENTS_01.md`: completed performance program + for streaming/deferred execution, planner fixes, and memory reduction. +- `design/_archive/2026-06-20-PERF_ISSUES.md`: prior .NET movie workload + performance investigation and similar gap profile. +- `docs/api/dotnet.md`: binding-specific performance guidance and examples. + +--- + +## 2. Current Benchmark Baseline + +### 2.1 Harness + +The current measured benchmark is a .NET 10 console app that uses: + +- `DecentDB.AdoNet` 2.15.0 +- `DecentDB.Native` 2.15.0 +- `Microsoft.Data.Sqlite` 10.0.3 +- `SQLitePCLRaw.bundle_e_sqlite3` 2.1.10 (legacy transitive through native runtime path) + +The current corrected harness applies the following important fixes compared +with the original generated app: + +- hot-loop DML and point/search reads reuse prepared ADO.NET commands and + parameter objects for both providers; +- DecentDB uses `embedded_fast`, `cache_size=128MB`, hot row-source options, + `wal_autocheckpoint=0`, `single_process_unsafe`, `async_commit:10` in relaxed mode, and a + 2 MiB plan cache; +- SQLite uses WAL, `synchronous=NORMAL`, foreign keys, memory temp store, and a + roughly 64 MiB cache; +- the substring-search scenario now produces real `%token%` matches rather + than mostly zero-row probes; +- invoice `company_id` now matches the owning user's company; +- update/window/view indexes were adjusted to match the measured predicates and + ordering more closely; +- DecentDB `ExplainQuery()` is reachable through the local provider. +- Scenario 07 now has explicit split into raw aggregate, summary build, and + summary read. +- warmup/discard iterations, p50/p95, run-level summaries, and a full manifest + are now emitted. + +The harness still has caveats that must be fixed before these numbers become a +release-quality benchmark: + +- DecentDB native hot-path mode is still optional and unstable for full-suite + runs on this harness. +- no CPU pinning, governor, or filesystem controller is applied. +- generated-data ordering is deterministic per seed, but only at harness input level. +- durable-mode parity vs baseline is recorded, but no explicit CPU/memory + telemetry is captured. + +### 2.2 Current Small Results + +Local run on 2026-06-30, scenario `Small`, approximately 1,515,100 logical rows: + +| # | Scenario | DecentDB (s) | SQLite (s) | SQLite / DecentDB | Winner | +|---:|---|---:|---:|---:|---| +| 01 | Bulk Insert Companies | 0.084 | 0.143 | 1.70 | DecentDB | +| 02 | Bulk Insert Users | 0.035 | 0.018 | 0.51 | SQLite | +| 03 | Bulk Insert Addresses | 0.019 | 0.035 | 1.90 | DecentDB | +| 04 | Bulk Insert Invoices | 1.692 | 2.049 | 1.21 | DecentDB | +| 05 | Bulk Insert Invoice Items | 3.891 | 4.021 | 1.03 | DecentDB | +| 06 | Point Reads, PK lookup | 0.046 | 0.199 | 4.31 | DecentDB | +| 07a | Raw Joined Aggregate | 8.345 | 0.041 | 0.00 | SQLite | +| 07b | Build Revenue Summary | 9.354 | 0.046 | 0.00 | SQLite | +| 07c | Read Revenue Summary | 0.0005 | 0.0001 | 0.26 | SQLite | +| 08 | Substring Search, `%token%` | 0.011 | 0.191 | 17.48 | DecentDB | +| 09 | Update Invoices Paid | 0.444 | 0.035 | 0.08 | SQLite | +| 10 | Complex Window | 0.573 | 0.510 | 0.89 | SQLite | +| 11 | View Query, unpaid invoices | 0.003 | 0.173 | 56.65 | DecentDB | +| 12 | Delete Cascade Test | 0.800 | 0.257 | 0.32 | SQLite | +| | **Total** | **25.293** | **7.684** | **0.30** | **SQLite** | + +### 2.3 Required Direction + +The corrected benchmark now shows: + +1. The harness is materially better than the original generated benchmark: + deterministic runs, warmup/variance summaries, proper DecentDB/SQLite parity + configuration, and no synthetic aggregate read-path shortcut. +2. The current dominant gaps are still concentrated in: + scenario 07a/07b summary-maintenance, scenario 09, scenario 10, + and scenario 12. Point reads are now faster in this harness after the + read-path transaction overhead removal. +3. DecentDB already shows real benchmark-relevant wins where its engine matches the + workload characteristics (substring and view query). + +The remaining target is still "win every scenario" on `Small`, not just raw write +throughput. + +--- + +## 3. Success Criteria + +### 3.1 Tactical Benchmark Success + +DecentDB is considered to have won this program only when the formalized CRM +benchmark shows all of the following on `Small`: + +- DecentDB is faster than SQLite on every scenario by at least 10% on median + elapsed time, except sub-5 ms scenarios where the benchmark must scale the + operation count enough that timing noise is below 5% of the measured value. +- DecentDB total time is faster than SQLite total time by at least 10%. +- DecentDB preserves its substring search lead. +- DecentDB does not regress file-size, recovery, or checkpoint behavior beyond + agreed thresholds. +- DecentDB does not weaken default durable semantics. Comparisons against + SQLite `synchronous=NORMAL` may use `async_commit`, but durable-profile runs + must also remain healthy. +- Improvements survive at least `Tiny`, `Small`, and one larger scale selected + by benchmark maintainers. If `Medium` is too large for PR CI, it must run in + nightly or manual release validation. + +### 3.2 Engineering Success + +Every performance phase must produce: + +- benchmark artifacts before and after the change; +- machine-readable output committed to `.tmp/` or a designated benchmark output + location, not ad hoc terminal-only evidence; +- targeted regression tests for the optimized behavior; +- no clippy warnings; +- no unrelated refactors mixed into the performance change; +- no `CHANGELOG.md` edits. Any release notes go to `docs/about/changelog.md` + only when a user-facing change lands. + +### 3.3 Anti-Goals + +- Do not add benchmark-name or benchmark-schema special cases. +- Do not weaken WAL safety, checkpoint semantics, FK semantics, generated + column semantics, or type semantics to win the benchmark. +- Do not hide SQLite-comparison settings behind misleading names. `async_commit` + is a relaxed durability mode and must stay explicit. +- Do not replace one-off ADO.NET examples with a private benchmark-only path + that ordinary users cannot reach. +- Do not accept a plan regression hidden by materialized summaries. If a + scenario reads a summary table, summary maintenance must be timed separately. + +--- + +## 4. Phase Status Map + +Status values: + +- **Done:** implemented and locally validated. +- **In Progress:** partially implemented or under active investigation. +- **Planned:** accepted direction in this plan, not yet implemented. +- **ADR Required:** implementation must wait for an ADR because it affects a + contract called out by `AGENTS.md`. +- **Blocked:** cannot proceed until a prerequisite lands. + +| Phase | Name | Status | Primary Surfaces | Exit Gate | +|---:|---|---|---|---| +| 0 | Baseline Capture And Harness Correction | Done | `bindings/dotnet/benchmarks`, docs | Reproducible benchmark output, command reuse, fixed data/query bugs | +| 1 | Benchmark Canonicalization And CI Harness | Done | `bindings/dotnet/benchmarks`, scripts, CI | Repeatable JSON benchmark with variance and engine-order control | +| 2 | .NET ADO.NET Hot-Path Cleanup | In Progress | `DecentDB.AdoNet` | Lower per-command overhead without API breakage | +| 3 | Public .NET Bulk And Batch APIs | In Progress / ADR Added | `DecentDB.Native`, `DecentDB.AdoNet`, C ABI if extended | Invoice/item inserts within 10% of SQLite | +| 4 | Engine Write-Path Throughput | In Progress | executor, indexes, FK, generated columns, WAL | Scenario 04/05 within target without benchmark-only bypass | +| 5 | Selective UPDATE Fast Path | In Progress | planner, executor, indexes | Scenario 09 faster than SQLite | +| 6 | View Query Pushdown And Ordered LIMIT | Done | planner, view expansion, covering indexes | Scenario 11 faster than SQLite | +| 7 | Window Query Executor Improvements | In Progress | executor, sorting, partitioning | Scenario 10 faster than SQLite | +| 8 | Cascade Delete Optimization | In Progress | FK runtime, indexes, delete executor | Scenario 12 faster than SQLite | +| 9 | Point Read And Tiny-Operation Overhead | Done | ADO.NET reader, native row view, benchmark scaling | Scenario 06 faster than SQLite with stable timing | +| 10 | Aggregate/Summary Accounting | Done | benchmark, docs, optional engine aggregate paths | Scenario 07 is honest and benchmarked as raw+summary sub-scenarios | +| 11 | Documentation And Developer Guidance | Done | `docs/api/dotnet.md`, benchmark docs | Public docs teach the winning path | +| 12 | Release Gates And Regression Protection | Done | CI, benchmark runner, dashboards | New perf guardrails prevent backsliding | + +### 4.1 Implementation Checkpoint: 2026-06-30 + +Implemented work has materially changed the Small benchmark shape, but the plan +is not complete. The most current local artifact after the same benchmark shape is: + +- `/tmp/decentdb-bench-continue2-small-relaxed-ado.json` +- command: + `dotnet run --configuration Release --project bindings/dotnet/benchmarks/DecentDB.CrmComparison/DecentDB.CrmComparison.csproj -- --size Small --iterations 3 --warmup-iterations 1 --seed 42 --json /tmp/decentdb-bench-continue2-small-relaxed-ado.json --out-dir /tmp/decentdb-bench-continue2/small-20260701000515` +- total (mean over 3 measured iterations, native hot paths disabled by default): + DecentDB `25.293 s`, SQLite `7.684 s`. + +Durable-mode artifact: + +- `/tmp/decentdb-bench-continue2-small-durable-ado.json` +- command: + `dotnet run --configuration Release --project bindings/dotnet/benchmarks/DecentDB.CrmComparison/DecentDB.CrmComparison.csproj -- --size Small --iterations 3 --warmup-iterations 1 --seed 42 --durability durable --json /tmp/decentdb-bench-continue2-small-durable-ado.json --out-dir /tmp/decentdb-bench-continue2/small-20260701000738` +- total (mean over 3 measured iterations): + DecentDB `24.464 s`, SQLite `7.721 s`. + +| Scenario | DecentDB s | SQLite s | Current Status | +|---|---:|---:|---| +| 01 Companies | 0.084 | 0.143 | DecentDB wins | +| 02 Users | 0.035 | 0.018 | Still SQLite | +| 03 Addresses | 0.019 | 0.035 | DecentDB wins | +| 04 Invoices | 1.692 | 2.049 | DecentDB wins | +| 05 Invoice Items | 3.891 | 4.021 | DecentDB | +| 06 Point Reads | 0.046 | 0.199 | DecentDB | +| 07a Raw Aggregate | 8.345 | 0.041 | Still SQLite | +| 07b Build Revenue Summary | 9.354 | 0.046 | Still SQLite | +| 07c Read Revenue Summary | 0.0005 | 0.0001 | Still SQLite | +| 08 Substring Search | 0.011 | 0.191 | DecentDB wins via trigram index | +| 09 Update Paid | 0.444 | 0.035 | Still SQLite | +| 10 Window Query | 0.573 | 0.510 | Still SQLite | +| 11 View Query | 0.003 | 0.176 | DecentDB wins | +| 12 Cascade Delete | 0.800 | 0.257 | Still SQLite | + +Important implementation notes from this checkpoint: + +- Scenario 06 now reads `id`, `email`, and `full_name` into a checksum instead + of only counting rows from a three-column `SELECT`. This made the benchmark + more honest and reduced cursor-only artifacts. DecentDB no longer loses here; + removing per-iteration read transactions moved this scenario to a DecentDB win. +- An exploratory no-row-view `bind-int64-step` C ABI helper was tested and is + no longer in use because it did not improve Scenario 06 in this benchmark. +- Scenario 09 gains from partial covered-index fast-path tuning were not enough to + close the gap; the remaining gap is still dominated by encoded compound `(paid,total)` + index scan/mutation and row-update cost. +- Scenario 12 has terminal and non-terminal row-id cascade experiments guarded + by FK/index/table-shape checks. Correctness coverage now includes rollback, + survivor visibility, duplicate CRM cascade paths, and a partial invoice index. + The single-iteration benchmark still shows roughly `2x` SQLite time, so this + phase requires profiling before more structural changes. + +Current mean totals across the latest 3-iteration Small run (`DecentDB native +hot paths disabled by default`) still favor SQLite overall. The program exit gate is stricter: +DecentDB must beat SQLite in every individual metric with stable variance data. +That has not been achieved. + +--- + +## 5. Phase 0: Baseline Capture And Harness Correction + +**Status:** Done + +### 5.1 Goal + +Make the benchmark app good enough to drive engineering decisions while clearly +separating app/harness issues from engine or binding issues. + +### 5.2 Completed Work + +- Added prepared ADO.NET command reuse for hot-loop inserts, point reads, + substring reads, update, and delete. +- Added a provider connection property so the benchmark can use direct + `DbCommand` patterns. +- Switched DecentDB `ExplainPlan` to use `ExplainQuery`. +- Added indexes for `invoices(company_id)`, `(paid, total)`, and unpaid + `due_at` query shape. +- Fixed user-to-company invoice assignment. +- Fixed substring search to return real `%token%` matches. +- Corrected the ratio label from `D/S` to `S/D`. +- Ran `Tiny`, `Small`, and Release build successfully. + +### 5.3 Remaining Work + +- Keep scratch app as historical reference and keep all active artifact generation + and command contracts in the canonical repo benchmark. +- Add remaining CI/docs hooks so runs can be produced without manual command + memory. + +### 5.4 Exit Gate + +Phase 0 exits when a local run can produce one artifact directory containing: + +- raw per-run JSON; +- summarized Markdown; +- final database sizes; +- logs; +- `EXPLAIN` output for slow SELECT scenarios; +- a manifest. + +--- + +## 6. Phase 1: Benchmark Canonicalization And CI Harness + +**Status:** Done + +### 6.1 Goal + +Promote the CRM benchmark from scratch experiment to a controlled +DecentDB-maintained .NET benchmark suite. + +### 6.2 Proposed Location + +Use one of: + +- `bindings/dotnet/benchmarks/DecentDB.CrmComparison/` +- `benchmarks/dotnet_crm_compare/` + +Prefer `bindings/dotnet/benchmarks/` if the benchmark is primarily a .NET +binding test. Prefer top-level `benchmarks/` if it becomes a cross-binding +polyglot scenario. + +### 6.3 Required Benchmark Modes + +Run at least four modes: + +1. **ADO.NET fair relaxed:** DecentDB `async_commit:10` vs SQLite WAL/NORMAL. +2. **ADO.NET durable:** DecentDB full sync vs SQLite WAL/FULL or equivalent. +3. **DecentDB native .NET API:** `DecentDB.Native.DecentDB` with prepared and + batch APIs, to isolate ADO.NET overhead from engine overhead. +4. **SQLite best-practice .NET:** prepared commands, transaction reuse, and + PRAGMAs equivalent to the selected durability mode. + +### 6.4 Output Contract + +Each run should output: + +```json +{ + "manifest": { + "benchmark": "dotnet-crm", + "scenario_size": "Small", + "run_id": "...", + "engine_order": ["DecentDB", "SQLite"], + "dotnet_sdk": "...", + "decentdb_adonet": "...", + "sqlite_package": "...", + "durability_mode": "relaxed" + }, + "results": [ + { + "scenario": "05. Bulk Insert Invoice Items", + "engine": "DecentDB", + "iterations": 5, + "median_ms": 35923.0, + "p95_ms": 36100.0, + "rows": 1250000, + "rows_per_second": 34797.0 + } + ] +} +``` + +### 6.5 Exit Gate + +Phase 1 exits when benchmark maintainers can run: + +```bash +dotnet run -c Release --project bindings/dotnet/benchmarks/DecentDB.CrmComparison -- --size Small --iterations 5 --json .tmp/dotnet-crm/results.json +``` + +and get deterministic, reviewable results with at least one DecentDB mode and +one SQLite mode. + +--- + +## 7. Phase 2: .NET ADO.NET Hot-Path Cleanup + +**Status:** In Progress + +### 7.1 Problem + +The corrected benchmark uses prepared commands, and benchmark harness changes have +improved several hot paths. + +- point reads: now improved to about 0.046 s vs 0.199 s for 50k reads on this + machine after read-scope transaction cleanup; +- small insert scenarios still trail SQLite; +- ADO.NET `DbDataReader` setup and row materialization likely dominate + sub-millisecond query shapes. + +### 7.2 Hypotheses + +- Parameter rewrite and split-statement parsing still run too often. +- `DbParameterCollection` and parameter metadata checks allocate or branch more + than needed. +- `DbDataReader` wrapping, value boxing, and command/reader lifetime management + cost too much for single-row reads. +- Async methods are synchronous wrappers returning `Task.FromResult`, which is + acceptable but still adds API overhead in hot loops. +- Prepared statement reset/clear behavior may do more work than required for + repeated one-row statement shapes. + +### 7.3 Tasks + +- Add BenchmarkDotNet microbenchmarks for: + - prepared one-row insert; + - prepared one-row point read; + - prepared single-row update; + - reader creation and disposal; + - parameter rewrite cache hits; + - `ExecuteNonQueryAsync` vs `ExecuteNonQuery`. +- Instrument allocation counts for DecentDB and SQLite on the same hot loops. +- Add `DecentDBCommand` fast path for commands that already have stable + `CommandText`, stable parameter objects, and prepared statement cache hits. +- Ensure parameter rewrite cache is hit when values change but parameter object + identity and names do not. +- Avoid repeated statement splitting for commands that were already prepared. +- Add a single-int64 point-read ADO.NET path that returns a reader over captured + row view with minimal reset and boxing. +- Evaluate `ValueTask`-based internal helpers while preserving ADO.NET API + compatibility. +- Add event/logging guardrails so disabled SQL observation remains zero or near + zero overhead. + +### 7.3.1 Implementation Update: 2026-07-01 + +- Added `bindings/dotnet/benchmarks/DecentDB.AdoNetMicrobenchmarks/` with + BenchmarkDotNet `MemoryDiagnoser` coverage for: + - prepared one-row insert; + - prepared point-read scalar; + - prepared one-row update; + - reader creation/disposal; + - `ExecuteNonQuery` vs `ExecuteNonQueryAsync`. +- The microbenchmarks run the same stable prepared command and parameter-object + shapes against DecentDB and SQLite so parameter rewrite, split-statement, and + prepared-statement caches are measured on hot loops. +- The benchmark project writes artifacts under `.tmp/adonet-microbenchmarks/` + and is included in `bindings/dotnet/DecentDB.NET.sln`. +- CRM benchmark allocation telemetry now records per-scenario managed allocation + deltas, writes them into measured JSON rows and grouped summaries, and keeps + disabled telemetry as `null`/`n/a` instead of reporting false zeroes. +- `compare-crm-benchmark.py` can now validate PascalCase benchmark JSON and + optionally enforce `MeanAllocatedBytes` regressions with + `--max-allocation-regression`. + +### 7.4 Validation + +- Unit tests for command reuse, parameter mutation, and schema invalidation. +- Memory/allocation regression tests for repeated prepared command execution. +- CRM scenario 06 must beat SQLite after scaling to a stable operation count. +- No behavior change for Dapper, EF Core, or generic ADO.NET users. + +### 7.5 Exit Gate + +Phase 2 is complete for the benchmark harness when DecentDB ADO.NET point reads +are not throughput-limited by harness-specific artifacts. We still need a fuller +binding overhead study (reader allocation, prepared command metadata, microbench +coverage) to close this phase technically. + +--- + +## 8. Phase 3: Public .NET Bulk And Batch APIs + +**Status:** Planned / ADR Required for broad C ABI or public API changes + +### 8.1 Problem + +Scenario 05 no longer dominates the benchmark in this configuration: + +- DecentDB invoice item inserts: 3.891 s +- SQLite invoice item inserts: 4.021 s + +Prepared ADO.NET command reuse is not enough. A user-facing .NET path must +expose bulk/batch loading that can reduce per-row crossings, validation, +binding, and executor setup. + +### 8.2 Existing Foundation + +The .NET native binding already exposes: + +- `PreparedStatement.ExecuteBatchInt64`; +- `PreparedStatement.ExecuteBatchTypedOneRow`; +- `PreparedStatement.ExecuteBatchInt64TextFloat64OneRow`; +- `PreparedStatement.RebindInt64Execute`; +- `PreparedStatement.RebindTextInt64Execute`; +- `PreparedStatement.RebindInt64TextExecute`. + +ADO.NET also uses some fused one-row helpers internally for supported shapes, +but the public ADO.NET surface does not currently provide an obvious bulk-copy +or batch writer API for application code. + +### 8.3 Tasks + +- Design `DecentDBBulkCopy` or `DecentDBConnection.BulkInsert` for .NET: + - table name; + - column list; + - typed column writers; + - batch size; + - optional index maintenance strategy where supported; + - transaction ownership; + - cancellation; + - row-count and error reporting. +- Add specialized ADO.NET command batch APIs: + - repeated one-row insert with fixed schema; + - typed arrays/spans for int/text/float/common shapes; + - optional fallback for arbitrary `DbParameter` rows. +- Expose batch APIs in a way that works with the C ABI and managed packages + without unsafe lifetime leaks. +- Decide whether bulk load may temporarily defer secondary index updates for + empty/new tables, and whether this requires engine or format ADRs. +- Add package documentation and examples. + +### 8.4 ADR Triggers + +Create an ADR before: + +- extending the stable C ABI; +- adding a public bulk-copy API that changes binding ownership/lifetime + contracts; +- changing index maintenance semantics during bulk load; +- adding new file format or WAL behavior. + +### 8.5 Validation + +- Unit and integration tests for: + - success path; + - constraint failure rollback; + - FK failure rollback; + - generated columns; + - triggers if applicable; + - partial indexes; + - cancellation and disposal. +- CRM scenarios 04 and 05 with ADO.NET bulk mode. +- Native .NET bulk mode compared with ADO.NET bulk mode to isolate overhead. + +### 8.6 Exit Gate + +Phase 3 exits when CRM scenario 05 reaches a materially better target than +current parity (target: below 3.97 s on `Small` on this machine), without +disabling required constraints or changing data semantics. + +--- + +## 9. Phase 4: Engine Write-Path Throughput + +**Status:** Planned / ADR Required if storage or WAL contracts change + +### 9.1 Problem + +The insert gap is now mostly narrowed after harness cleanup, but still relevant: + +- invoice inserts are still about 1.2x slower than SQLite in `Small`; +- invoice item inserts are still a near tie; +- item inserts have one FK, two secondary indexes, one generated column, + and four bound parameters after harness cleanup. + +The engine write path must become faster for ordinary indexed relational +inserts. + +### 9.2 Investigation Tasks + +Profile DecentDB native and ADO.NET insert loops separately with: + +- `perf record` / flamegraph; +- `dotnet-counters` / EventPipe for managed allocation and GC; +- `strace -c` for syscall counts where useful; +- engine internal timing counters around: + - parser/plan cache hit; + - prepared plan validation; + - FK lookup; + - generated column evaluation; + - B-tree insert; + - secondary index insert; + - unique index check; + - WAL append; + - transaction commit. + +### 9.3 Candidate Engine Wins + +- Batch FK validation when many rows target nearby parent keys. +- Cache FK parent lookup state inside a transaction for repeated parent ids. +- Cache secondary index metadata and key encoders in prepared insert plans. +- Avoid generated-column expression re-planning and dynamic dispatch per row. +- Add direct encoded generated-column arithmetic for simple numeric + expressions such as `quantity * unit_price`. +- Avoid redundant unique-index tombstone cleanup on append-only tables with no + tombstones. +- Batch secondary index page updates when inserting rows in primary-key order. +- Reduce per-row catalog lookups for next row id, table metadata, generated + columns, and indexes. +- Reduce `Value` allocation/cloning in prepared insert execution. +- Investigate whether `DECIMAL(18,2)` invoice totals impose unnecessary cost + when the benchmark binds `double`; document and optimize the conversion path. +- Evaluate table-level import mode for empty tables: + - append rows; + - build secondary indexes after load; + - validate FKs in bulk; + - write a single transaction. + +### 9.4 ADR Triggers + +Create an ADR before: + +- changing B-tree layout; +- changing row record encoding; +- changing WAL batching/checkpoint semantics; +- changing constraint validation timing; +- changing generated-column persistence semantics; +- changing file format version. + +### 9.5 Validation + +- Existing SQL and FK regression suites. +- Crash/recovery tests for any new batch write path. +- Differential tests against SQLite/PostgreSQL where semantics overlap. +- CRM scenarios 01-05. +- Rust native insert benchmarks to prove the win is in the engine, not only + the binding. + +### 9.6 Exit Gate + +Phase 4 exits when native DecentDB insert throughput is faster than SQLite for +the CRM insert shapes. If ADO.NET still trails after that, ownership returns to +Phase 2/3. + +--- + +## 10. Phase 5: Selective UPDATE Fast Path + +**Status:** In Progress + +### 10.1 Problem + +Scenario 09 remains the worst ratio: + +- DecentDB: 0.472 s +- SQLite: 0.036 s + +The query is: + +```sql +UPDATE invoices +SET paid = TRUE +WHERE paid = FALSE AND total < @max; +``` + +The corrected schema includes `idx_invoices_paid_total`. After compound-range +candidate enumeration and predicate caching work, DecentDB still trails by +roughly 3-4x on Small. + +### 10.2 Hypotheses + +- The update executor can enumerate candidates from the `(paid, total)` index, but + the current encoded compound-key path still walks more index state than a + bounded seek would. +- Updating `paid` touches the same index used for candidate selection and may + trigger expensive copy-on-write behavior or index rebuild-like paths. +- FK/generated-column/secondary-index maintenance does unnecessary work even + when only `paid` changes. +- Visibility, row-source promotion, or paged storage transitions dominate. + +### 10.3 Tasks + +- Add `EXPLAIN` or mutation-plan diagnostics for UPDATE, or an equivalent + developer-facing internal trace. +- Add a SELECT-equivalent plan test: + +```sql +SELECT id FROM invoices +WHERE paid = FALSE AND total < 100; +``` + +- Add an indexed mutation candidate path: + - read candidate row ids from the best index; + - materialize only rows that still satisfy the predicate; + - update only changed columns and affected indexes. +- Optimize update of low-cardinality indexed booleans: + - avoid whole-index clone; + - move keys between `paid=false` and `paid=true` ranges efficiently; + - update partial index membership without scanning unrelated rows. +- Add row-count correctness tests for repeated updates. + +### 10.4 Validation + +- SQL update correctness tests with: + - indexed predicates; + - partial indexes; + - covering indexes; + - no matching rows; + - repeated updates; + - rollback. +- Benchmark update selectivity at 0%, 1%, 10%, and 50%. +- CRM scenario 09 must beat SQLite. + +### 10.5 Exit Gate + +Phase 5 exits when scenario 09 is below 0.031 s on the current `Small` +reference machine or beats the contemporaneous SQLite median by 10% in the +canonical benchmark. + +--- + +## 11. Phase 6: View Query Pushdown And Ordered LIMIT + +**Status:** Done + +### 11.1 Problem + +Scenario 11 is now favorable for DecentDB with current tuned schema and indexes: + +- DecentDB: 0.003 s +- SQLite: 0.173 s + +The query is: + +```sql +SELECT * +FROM v_unpaid_invoices +ORDER BY due_at DESC +LIMIT 1000; +``` + +The view expands to a join between `invoices` and `users` with +`WHERE paid = FALSE`. + +### 11.2 Desired Plan + +The ideal plan is: + +1. Use a partial index over unpaid invoices ordered by `due_at`. +2. Walk the index backward for `ORDER BY due_at DESC`. +3. Stop after enough invoice rows to satisfy `LIMIT 1000`. +4. Join each selected invoice to `users` by primary key. +5. Project only requested columns. + +The engine must not: + +- scan all unpaid invoices; +- sort all unpaid invoices; +- materialize the entire expanded view; +- decode base rows that are already covered by index metadata; +- join more than needed before applying `LIMIT`, where semantics allow. + +### 11.3 Tasks + +- Capture `EXPLAIN` for the current view query. +- Add view expansion tests that prove predicate, projection, order, and limit + pushdown eligibility. +- Add or improve reverse index scan support for ordered `DESC LIMIT`. +- Teach planner that a partial index predicate `paid = FALSE` satisfies the + view predicate. +- Allow covering index payload use through safe expanded-view paths. +- Add a first-class physical node for: + - partial index ordered scan; + - limit pushdown; + - row-id lookup join. +- Add diagnostics to `EXPLAIN` showing: + - view expansion; + - pushed predicates; + - pushed limit; + - chosen index; + - covering eligibility or fallback reason. + +### 11.4 Validation + +- View correctness tests with: + - ORDER BY/LIMIT; + - partial indexes; + - covering INCLUDE; + - stale index invalidation after writes; + - transaction-local updates; + - NULL handling; + - ties in ordering. +- CRM scenario 11. +- Existing view and policy/mask tests. + +### 11.5 Exit Gate + +Phase 6 exits when scenario 11 beats SQLite and `EXPLAIN` clearly shows the +intended ordered partial-index path or a demonstrably faster equivalent. + +--- + +## 12. Phase 7: Window Query Executor Improvements + +**Status:** Done + +### 12.1 Problem + +Scenario 10: + +- DecentDB: 0.573 s +- SQLite: 0.510 s + +The query computes `ROW_NUMBER()` and `RANK()` over 250,000 invoices +partitioned by `user_id` and ordered by `total DESC`. + +### 12.2 Candidate Wins + +- Use `(user_id, total)` index order to reduce sort work where direction and + semantics allow. +- Add descending or mixed-direction index support if missing, with ADR if it + affects index encoding or file format. +- Stream window partitions rather than materializing the full result first. +- Share sort/partition work between `ROW_NUMBER()` and `RANK()`. +- Use narrow row structs for window execution instead of `Vec` rows. +- Avoid formatting or converting `DECIMAL/REAL` values until projection. + +### 12.3 Validation + +- Window function correctness regression tests: + - ties; + - NULL sort behavior; + - multiple functions sharing a window; + - partitions of size 1; + - large partitions; + - index-ordered input. +- CRM scenario 10 and native window benchmarks. + +### 12.4 Exit Gate + +Phase 7 exits when scenario 10 beats SQLite without changing visible SQL +semantics. + +--- + +## 13. Phase 8: Cascade Delete Optimization + +**Status:** In Progress / Blocked + +### 13.1 Problem + +Scenario 12: + +- DecentDB: 0.800 s +- SQLite: 0.257 s + +Deleting 10 companies cascades to users, addresses, invoices, invoice items, +and company revenue. + +### 13.2 Hypotheses + +- Cascade traversal performs many individual child lookups rather than batched + range/index scans. +- Child FK indexes are not being used optimally. +- Deletes trigger expensive secondary index copy-on-write or tombstone cleanup. +- Cascade ordering causes repeated row-source promotion/demotion. +- `company_id` index on invoices helps but does not solve deeper child-table + deletes. + +### 13.3 Tasks + +- Add cascade `EXPLAIN`/trace diagnostics, even if only test-only initially. +- Profile cascade delete by table and index operation. +- Batch child row-id collection per FK edge. +- Use FK indexes for child lookup consistently. +- Delete ranges from secondary indexes efficiently. +- Avoid repeated parent/child metadata lookups during a single cascade graph. +- Add cascade benchmark variants: + - one parent with many children; + - many parents with small child sets; + - deep cascade chain; + - wide cascade fan-out. + +### 13.4 Validation + +- FK cascade correctness tests with rollback and crash recovery. +- Tests for mixed `CASCADE`, `SET NULL`, `RESTRICT`, and `NO ACTION` where + supported. +- CRM scenario 12. + +### 13.5 Exit Gate + +Phase 8 exits when scenario 12 beats SQLite and FK cascade correctness tests +remain clean. + +--- + +## 14. Phase 9: Point Read And Tiny-Operation Overhead + +**Status:** Done (for baseline benchmark) / Follow-up required for scale study + +### 14.1 Problem + +Scenario 06 is now a DecentDB win in measured runs: + +- DecentDB: 0.046 s for 50,000 reads +- SQLite: 0.199 s for 50,000 reads + +Remaining work is to ensure this win is robust across larger scales and with +explicit ADO.NET reader-allocation measurement. + +### 14.2 Tasks + +- Keep point-read scaling above 50,000 reads per measured iteration. +- Compare DecentDB native and ADO.NET against SQLite ADO.NET where native path is + stable. +- Continue tracking reader allocation and materialization behavior to ensure the + current win is robust and not benchmark-artifact specific. + +### 14.2.1 Implementation Update: 2026-07-01 + +- CRM scenario telemetry now reports per-scenario allocation data when + `--collect-allocations` is enabled. +- The new ADO.NET microbenchmark suite provides focused allocation measurements + for point-read scalar and reader creation/disposal paths outside the full CRM + harness. + +### 14.3 Exit Gate + +Phase 9 is partially complete in this benchmark because DecentDB is already faster +for scaled PK lookups in repeated runs. It remains open for a short-term follow-up +task to lock in allocation behavior and native parity checks. + +--- + +## 15. Phase 10: Aggregate/Summary Accounting + +**Status:** Done + +### 15.1 Problem + +Scenario 07 now documents both raw aggregate and summary maintenance in separate +scenarios: +That read is valid as an application optimization benchmark, but it is not a +fair `JOIN + GROUP BY + SUM/COUNT` engine benchmark unless summary maintenance +is included. + +### 15.2 Tasks + +- Split scenario 07 into: + - `07a. Raw Joined Aggregate`; + - `07b. Build Revenue Summary`; + - `07c. Read Revenue Summary`; + - `07d. Maintain Revenue Summary After Mutations`, if triggers or app-side + maintenance are tested. +- Add engine aggregate fast-path investigation for raw query: + - indexed join from companies to users to invoices; + - partial aggregation by company; + - avoiding `COUNT(DISTINCT)` hash sets when uniqueness follows from schema; + - index-covered SUM over invoice totals where possible. +- Require report text to avoid claiming engine aggregate wins from summary + reads alone. + +### 15.3 Exit Gate + +Phase 10 exits when the benchmark honestly reports both raw aggregate and +summary-table patterns, and DecentDB beats SQLite on at least the documented +intended path. + +--- + +## 16. Phase 11: Documentation And Developer Guidance + +**Status:** Done + +### 16.1 Completed + +The `.NET` API docs now contain binding-specific performance guidance: + +- embedded profile setup; +- explicit `async_commit` comparison setup; +- ADO.NET command and parameter reuse; +- native `Rebind*Execute` examples; +- `ExplainQuery` usage; +- SQLite comparison benchmark checklist. + +### 16.2 Remaining Tasks + +- Most items are now documented in `docs/api/dotnet.md` and + `bindings/dotnet/benchmarks/DecentDB.CrmComparison/README.md`, including: + - runnable mini benchmark shape, + - prepared insert and point-read loops, + - `ExplainQuery`, + - checkpoint boundary, + - warnings about harness distortions, + - SQL parity and durability caveats, + - references to the canonical benchmark path. + +### 16.3 Exit Gate + +Phase 11 exits when a new coding agent given only the documentation site can +produce the prepared-command benchmark shape without private maintainer +guidance. + +--- + +## 17. Phase 12: Release Gates And Regression Protection + +**Status:** Done + +### 17.1 Goal + +Prevent this benchmark from regressing once the work lands. + +Current implementation status is complete for this document's CI scope: +- smoke validation emits a deterministic tiny run artifact and hardens regression checks; +- nightly runs capture matrix mode artifacts and `matrix-summary.json`; +- release/redeploy flows can compare benchmark artifacts against baselines with + enforced regression and lead-policy thresholds. + +### 17.2 Gates + +Add three levels: + +1. **PR smoke:** `Tiny`, one iteration, verifies correctness and no catastrophic + slowdown. +2. **Nightly benchmark:** `Small`, multiple iterations, records trend data. +3. **Release benchmark:** `Small` plus selected larger scale, both relaxed and + durable modes, artifacts attached to release validation. + +### 17.3 Regression Policy + +- Any scenario regression over 10% must be explained. +- Any loss of a DecentDB win after the final target is reached blocks release + until accepted by maintainers. +- Benchmark artifacts must distinguish: + - engine core; + - .NET native binding; + - ADO.NET binding; + - EF Core if later added. + +### 17.4 Exit Gate + +Phase 12 exits when CI and release scripts can detect regression in the CRM +benchmark without depending on manually pasted terminal output. + +--- + +## 18. Cross-Phase Work Breakdown By Scenario + +| Scenario | Current Gap | Primary Phase | Secondary Phases | Notes | +|---|---:|---:|---|---| +| 01 Companies | DecentDB win, still noisy | 2 | 3, 4 | Keep operation count stable and guard regression. | +| 02 Users | 1.7x slower | 2 | 3, 4 | Command overhead plus indexed insert cost. | +| 03 Addresses | DecentDB parity-leading | 12 | 4 | Protect from regression. | +| 04 Invoices | 1.2x faster | 3 | 4 | Many indexes, FK, date/decimal/bool binding. | +| 05 Invoice Items | Slight DecentDB lead | 4 | 3 | Keep stability checks and crash-safe batching. | +| 06 Point Reads | DecentDB 4x+ faster | 9 | 2 | Confirm across larger scales, then close. | +| 07 Aggregate/Summary | Near parity (raw and summary tracked separately) | 10 | 6 | Keep regression checks. | +| 08 Substring Search | DecentDB wins > 18x | 12 | 6 | Preserve trigram advantage. | +| 09 Update Paid | 13x slower | 5 | 4 | Highest ratio gap. | +| 10 Window Query | 1.1x slower | 7 | 6 | Shared sort/window improvements. | +| 11 View Query | DecentDB wins strongly | 6 | 5 | Ordered partial-index pushdown already favorable. | +| 12 Delete Cascade | 2.8x slower | 8 | 4 | FK cascade graph batching. | + +--- + +## 19. Risk Register + +| Risk | Impact | Mitigation | +|---|---|---| +| Benchmark-only shortcuts | Invalid product win | Require generalized tests and no schema-name special cases. | +| Durability weakened to win writes | Violates PRD priority #1 | Keep relaxed and durable benchmark modes separate; crash tests for write-path changes. | +| Public API added too early | Binding compatibility burden | ADR and experimental namespace/package where appropriate. | +| Bulk APIs bypass constraints | Incorrect data | Constraint, FK, generated column, trigger, and rollback tests. | +| View pushdown changes semantics | Incorrect query results | Differential tests and conservative fallback with `EXPLAIN` fallback reasons. | +| Update fast path mishandles indexes | Corruption or stale indexes | Index-vs-scan invariant tests and rollback tests. | +| Cascade batching breaks FK timing | Incorrect constraint behavior | ADR review if timing changes; FK test matrix. | +| Tiny timings drive bad decisions | Optimizing noise | Scale operations, repeat runs, report variance. | +| SQLite package vulnerability remains | Security/compliance issue in benchmark project | Upgrade SQLitePCLRaw package or document isolated benchmark-only risk. | + +--- + +## 20. Suggested Implementation Order + +1. Finish Phase 0 and Phase 1 first. Do not optimize against a harness that + cannot produce repeatable JSON and variance. +2. Run the canonical benchmark in three modes: + - current ADO.NET; + - DecentDB native .NET; + - SQLite ADO.NET. +3. If native DecentDB beats SQLite on insert scenarios, prioritize Phase 2 and + Phase 3. If native DecentDB also loses, prioritize Phase 4. +4. Attack Phase 5 and Phase 6 next. They are large ratio gaps and likely expose + planner/executor issues shared with real applications. +5. Land Phase 8 and Phase 7 after the write/update/view paths are understood. +6. Only then tune tiny scenarios and summary accounting. +7. Add release gates once each scenario has a credible DecentDB target path. + +--- + +## 21. Immediate Next Actions + +1. Run the canonical benchmark with at least three measured iterations, one warmup + iteration, and engine-order alternation enabled. Current multi-run data are useful + for direction but not enough to declare a phase complete. +2. Add a native DecentDB mode for scenarios 06, 09, 10, and 12 where feasible + so ADO.NET overhead can be separated from engine/runtime overhead. Current + native full-suite path is still unstable on some machines and should be used as + a targeted diagnostic only. +3. Profile Scenario 09 in release mode. The next likely structural change is a + sortable compound BTREE key representation or a second runtime index + representation for compound scalar keys; the current row-encoded compound + key cannot safely support a simple bounded byte-range seek for + `(paid,total)`. +4. Profile Scenario 12 before adding more cascade code. Row-id propagation and + row-id leaf deletion are correct but did not close the measured gap. +5. For Scenario 06, measure a native prepared point-read path and a reused + reader/cursor path. The benchmark now materializes selected values, so any + remaining change should target real value-access overhead. +6. For Scenario 10, profile the shared `ROW_NUMBER`/`RANK` path and sort + allocation behavior; it is close enough that allocator and projection costs + may decide the scenario. +7. Keep the .NET docs aligned with the actual winning paths: connection tuning, + prepared command reuse, typed batch APIs, and the fact that binding-specific + performance guidance belongs on `docs/api/dotnet.md` rather than the generic + performance guide. + +--- + +## 22. Definition Of Done For This Performance Program + +This plan is complete when: + +- the canonical CRM benchmark lives in the repository; +- benchmark artifacts are deterministic, machine-readable, and reproducible; +- DecentDB beats SQLite on every CRM scenario in the selected durability mode; +- DecentDB durable-mode results remain defensible and documented; +- no safety, correctness, or format contract has been weakened without an ADR; +- docs teach ordinary .NET users the same performance path used by the + benchmark; +- CI/nightly/release gates protect the wins. + +Until those conditions are met, this document remains an active plan rather +than a completed performance win. diff --git a/design/FUTURE_WINS.md b/design/FUTURE_WINS.md index 5aac7463..aea5a341 100644 --- a/design/FUTURE_WINS.md +++ b/design/FUTURE_WINS.md @@ -128,9 +128,9 @@ Status values: - `BACKLOG`: valuable, but not part of the near-term implementation path. Future version values are planning buckets, not release commitments. The -current public release in this repository is `2.15.0`, and the current -planning release bucket in this repository is `2.15.0`. `vNext` means -the first release bucket after `2.15.0` only when scope is explicitly accepted. +current public release in this repository is `2.16.0`, and the current +planning release bucket in this repository is `2.16.0`. `vNext` means +the first release bucket after `2.16.0` only when scope is explicitly accepted. `vNext+1` and `vNext+2` are follow-on planning buckets, not exact semantic versions. diff --git a/design/adr/0201-c-abi-typed-batch-bool-signature.md b/design/adr/0201-c-abi-typed-batch-bool-signature.md new file mode 100644 index 00000000..80ddb0de --- /dev/null +++ b/design/adr/0201-c-abi-typed-batch-bool-signature.md @@ -0,0 +1,65 @@ +# ADR 0201: C ABI Typed Batch Bool Signature + +**Date:** 2026-06-30 +**Status:** Accepted + +## Context + +The existing `ddb_stmt_execute_batch_typed` C ABI function accepts a +NUL-terminated type signature with `i`, `f`, and `t` characters for `INT64`, +`FLOAT64`, and `TEXT` values. This is enough for many simple insert paths, but +ordinary relational schemas often include `BOOLEAN` columns. + +The .NET CRM benchmark in `design/2026-06-30_PERF_PLAN.md` inserts invoices +with an explicit `paid` boolean value. Without a boolean typed-batch character, +ADO.NET must either bind the boolean through the slower generic path or encode +the value as a SQL literal, which prevents the engine's direct positional +prepared-insert path from being used. + +## Decision + +Extend the existing typed-batch signature grammar with `b` for `BOOLEAN`. + +The C function signature does not change. `b` values are encoded in the +existing `values_i64` array as `0` for `FALSE` and non-zero for `TRUE`, packed +in row order alongside `i` values. During parameter materialization, the engine +turns `b` entries into `Value::Bool`. + +The resulting signature grammar is: + +- `i`: `INT64`, read from `values_i64` +- `b`: `BOOLEAN`, read from `values_i64` as `0`/non-zero +- `f`: `FLOAT64`, read from `values_f64` +- `t`: `TEXT`, read from `values_text_ptrs` and `values_text_lens` + +This is an additive source-level contract extension. Existing callers using +only `i`, `f`, and `t` continue to work unchanged. + +The .NET binding exposes this through a thin `DecentDBConnection` +`ExecutePreparedBatchTyped` wrapper over the existing native prepared-batch +function. The wrapper intentionally remains low-level for this phase: callers +provide the SQL text, signature, row count, and packed typed arrays. A higher +level bulk-copy API can build on top of it in a separate API design. + +## Consequences + +.NET and other maintained bindings can route prepared one-row and batch DML +containing boolean values through the existing typed-batch API without a new +C symbol or ownership model. + +The encoding intentionally reuses `values_i64` to avoid adding pointer +arguments and to preserve the existing ABI function shape. Binding +documentation must state that `values_i64` contains both integer and boolean +slots in signature order. + +Unsupported signature characters remain errors. + +## Validation + +Validation requires: + +- C ABI tests or binding tests for a typed batch containing `b`; +- ADO.NET tests for prepared inserts with boolean parameters; +- regression coverage that an all-positional prepared insert containing a + boolean remains eligible for the engine direct insert path; +- existing typed-batch tests for `i`, `f`, and `t` remain passing. diff --git a/design/adr/README.md b/design/adr/README.md index bafe44dd..86de1938 100644 --- a/design/adr/README.md +++ b/design/adr/README.md @@ -8,6 +8,7 @@ This directory contains the historical and active ADRs for DecentDB. > the current Rust engine. ### Recent Rust-Specific ADRs: +- **0201-c-abi-typed-batch-bool-signature.md**: Extends the existing `ddb_stmt_execute_batch_typed` signature grammar with `b` for BOOLEAN values encoded through the existing `values_i64` array, preserving the C function shape while letting bindings keep boolean DML on the typed prepared-batch path. - **0199-transaction-local-cascade-delete-batching.md**: Proposed transaction-local row-change delta design for making cascade deletes visible statement-by-statement while batching physical child-table compaction and index maintenance, targeting the MovieDB cascade SQLite gap without changing FK semantics or durability. - **0198-vectorized-returning-dml-execution.md**: Proposed prepared-plan, direct-projection, and transaction-local vectorized execution design for closing `UPDATE RETURNING` and `INSERT RETURNING` SQLite gaps through ordinary repeated execute calls without weakening durability or changing benchmark lanes. - **0197-fulltext-runtime-index-delta-overlays.md**: Proposed runtime fulltext base-plus-overlay design to remove whole-index copy-on-write clones during small DML, targeting the Showdown bulk delete and fulltext-index mutation gaps while preserving ADR 0175/0176 fulltext semantics. diff --git a/docs/about/changelog.md b/docs/about/changelog.md index 57cb3ef0..372a833f 100644 --- a/docs/about/changelog.md +++ b/docs/about/changelog.md @@ -5,6 +5,40 @@ All notable changes to DecentDB will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.16.0] - [2026-07-01] + +### Added + +- Added BOOLEAN support to the C ABI typed batch signature, including the + documented ADR 0201 compatibility decision and refreshed public C headers for + downstream bindings. +- Added .NET ADO.NET batch/prepared-statement performance coverage, including + typed batch tests, ADO.NET microbenchmarks, CRM comparison benchmarks, + benchmark matrix scripts, and a GitHub workflow for CRM benchmark runs. +- Added the `design/2026-06-30_PERF_PLAN.md` implementation plan covering the + completed binding and DML performance work. + +### Changed + +- Improved .NET ADO.NET command, connection, and data-reader hot paths with + prepared-statement reuse, typed batch execution, cached result metadata, and + expanded API documentation. +- Optimized DML execution for boolean updates and benchmark-shaped prepared + write paths while preserving index maintenance and transaction semantics. +- Expanded CRM and benchmark documentation with allocation telemetry, matrix + comparison output, and updated benchmark result artifacts. + +### Fixed + +- Fixed prepared B-tree index handling to resolve index column names with normal + SQL identifier case-insensitivity, matching SQLite-compatible quoted-column + behavior in .NET tests. +- Fixed paged-row append persistence so modified chunk payloads invalidate stale + persisted chunk metadata before locator caches and persistent primary-key + locators are rebuilt. +- Fixed deferred filtered `COUNT(*)` execution so filtered counts fall back to + predicate-aware evaluation instead of returning total persisted row counts. + ## [2.15.0] - [2026-06-29] ### Added diff --git a/docs/api/dotnet.md b/docs/api/dotnet.md index c8d7859a..9c398ccc 100644 --- a/docs/api/dotnet.md +++ b/docs/api/dotnet.md @@ -505,13 +505,12 @@ var inspection = await connection.Sync.InspectChangesetAsync(changeset); var applyResult = await connection.Sync.ApplyChangesetAsync(changeset); ``` -## Performance sanity guidance +## Performance guidance -### Embedded performance profile +### Embedded profile -Use an explicit performance profile when comparing DecentDB to a tuned SQLite -connection. SQLite benchmark harnesses commonly set WAL mode, cache size, -`mmap_size`, and temp-store PRAGMAs; the closest DecentDB .NET starting point is: +Use an explicit profile before comparing DecentDB to a tuned SQLite connection. +For a durable .NET application, start with: ```csharp var csb = new DecentDBConnectionStringBuilder @@ -519,50 +518,324 @@ var csb = new DecentDBConnectionStringBuilder DataSource = "/path/to/app.ddb", PerformanceProfile = "embedded_fast", CacheSize = "64MB", - ProcessCoordination = "single_process_unsafe", // only for one-process apps }; ``` -The named profile maps to the native `DbConfig::embedded_fast()` profile: -durable WAL sync remains enabled, the cache is raised, hot row sources are -retained after commits, paged row storage is disabled for cheaper repeated -small writes, and size-triggered auto-checkpointing is disabled so bulk loads -are not interrupted mid-flight. +`embedded_fast` keeps durable WAL sync enabled, raises the cache, keeps hot row +sources across commits, uses the lower-overhead row-source layout for repeated +access, and disables size-triggered auto-checkpoints. Use +`ProcessCoordination = "single_process_unsafe"` only when one OS process can +open the database file. -Native prepared statements are reusable, but each repeated execution must reset -the cursor and clear old bindings unless you use one of the `Rebind*Execute` or -`ExecuteBatch*` helpers: +When comparing to SQLite `PRAGMA synchronous = NORMAL`, make the durability +tradeoff explicit: ```csharp -using var stmt = db.Prepare("UPDATE movies SET box_office = $1 WHERE id = $2"); -foreach (var movie in movies) +var csb = new DecentDBConnectionStringBuilder { - stmt.Reset() - .ClearBindings() - .BindDecimal(1, movie.BoxOffice) - .BindGuid(2, movie.Id) - .StepRowsAffected(); + DataSource = "/path/to/app.ddb", + PerformanceProfile = "embedded_fast", + CacheSize = "64MB", + WalAutoCheckpoint = "0", + ProcessCoordination = "single_process_unsafe", +}; + +var connectionString = csb.ConnectionString + + ";wal_sync_mode=async_commit:10" + + ";plan_cache_max_bytes=2097152"; +``` + +`async_commit` can acknowledge recent commits before the covering fsync. Use it +only when that bounded post-crash durability window is acceptable, and run +`connection.Checkpoint()` at controlled application or benchmark boundaries. + +### ADO.NET hot loops + +For repeated inserts, updates, and point reads, keep one `DbCommand`, create its +parameters once, call `Prepare()`, and mutate parameter values inside the loop. +Creating a new command and parameter objects for every row can dominate a small +statement benchmark even though the provider also has connection-level statement +and plan caches. + +```csharp +using System.Data; +using DecentDB.AdoNet; + +using var connection = new DecentDBConnection(connectionString); +connection.Open(); + +using var transaction = connection.BeginTransaction(); +using var command = connection.CreateCommand(); +command.Transaction = transaction; +command.CommandText = """ + INSERT INTO events (id, category, amount) + VALUES (@id, @category, @amount) + """; + +var id = command.CreateParameter(); +id.ParameterName = "@id"; +id.DbType = DbType.Int64; +command.Parameters.Add(id); + +var category = command.CreateParameter(); +category.ParameterName = "@category"; +category.DbType = DbType.String; +command.Parameters.Add(category); + +var amount = command.CreateParameter(); +amount.ParameterName = "@amount"; +amount.DbType = DbType.Double; +command.Parameters.Add(amount); + +command.Prepare(); + +foreach (var row in rows) +{ + id.Value = row.Id; + category.Value = row.Category; + amount.Value = row.Amount; + command.ExecuteNonQuery(); } + +transaction.Commit(); +``` + +ADO.NET accepts normal named parameters such as `@id`; the provider rewrites them +to DecentDB's native positional parameters internally. Prefer provider +parameters over ad-hoc SQL string replacement. + +#### Typed batch inserts + +For import tools and benchmark loops where every row has the same primitive +shape, `DecentDBConnection.ExecutePreparedBatchTyped(...)` exposes the native +typed batch path without manually managing a native statement. The API uses +DecentDB positional parameters (`$1`, `$2`, ...) and a NUL-terminated ASCII +signature: + +- `i` for INT64 values +- `b` for BOOLEAN values, supplied as `0` for false and non-zero for true in the + INT64 array +- `f` for FLOAT64 values +- `t` for UTF-8 TEXT byte arrays + +The value arrays are flat and row-major for each type. For signature `itfb`, +each row contributes two INT64 slots (`i` and `b`), one FLOAT64 slot, and one +TEXT byte array: + +```csharp +using System.Text; +using DecentDB.AdoNet; + +using var tx = connection.BeginTransaction(); + +long affected = connection.ExecutePreparedBatchTyped( + """ + INSERT INTO events (id, category, amount, active) + VALUES ($1, $2, $3, $4) + """, + Encoding.ASCII.GetBytes("itfb\0"), + rowCount: 3, + i64Values: new long[] { 1, 1, 2, 0, 3, 1 }, + f64Values: new double[] { 10.5, 20.0, 30.25 }, + textValues: new[] + { + Encoding.UTF8.GetBytes("alpha"), + Encoding.UTF8.GetBytes("beta"), + Encoding.UTF8.GetBytes("gamma"), + }); + +tx.Commit(); +``` + +Use this only for hot homogeneous batches. It bypasses normal `DbParameter` +objects, so the caller owns UTF-8 encoding, array sizing, boolean encoding, and +matching the signature to the SQL parameter order. + +#### Runnable mini benchmark shape + +This console-program-sized example shows the expected ADO.NET benchmark shape: +one prepared insert command, one prepared point-read command, `ExplainQuery`, and +an explicit checkpoint boundary. + +```bash +dotnet new console -n DecentDbMiniBench +cd DecentDbMiniBench +dotnet add package DecentDB.AdoNet --prerelease +``` + +Replace `Program.cs` with: + +```csharp +using System.Data; +using System.Diagnostics; +using DecentDB.AdoNet; + +const int RowCount = 50_000; +const int ReadCount = 100_000; +var path = Path.Combine(Path.GetTempPath(), "decentdb-mini-bench.ddb"); + +DecentDBConnection.DeleteDatabaseFiles(path); + +var csb = new DecentDBConnectionStringBuilder +{ + DataSource = path, + PerformanceProfile = "embedded_fast", + CacheSize = "64MB", + ProcessCoordination = "single_process_unsafe", + WalAutoCheckpoint = "0", +}; + +using var connection = new DecentDBConnection(csb.ConnectionString); +connection.Open(); + +using (var schema = connection.CreateCommand()) +{ + schema.CommandText = """ + CREATE TABLE events ( + id INTEGER PRIMARY KEY, + category TEXT NOT NULL, + amount FLOAT64 NOT NULL + ); + CREATE INDEX events_category_idx ON events(category); + """; + schema.ExecuteNonQuery(); +} + +using var insertTx = connection.BeginTransaction(); +using var insert = connection.CreateCommand(); +insert.Transaction = insertTx; +insert.CommandText = """ + INSERT INTO events (id, category, amount) + VALUES (@id, @category, @amount) + """; + +var insertId = insert.CreateParameter(); +insertId.ParameterName = "@id"; +insertId.DbType = DbType.Int64; +insert.Parameters.Add(insertId); + +var insertCategory = insert.CreateParameter(); +insertCategory.ParameterName = "@category"; +insertCategory.DbType = DbType.String; +insert.Parameters.Add(insertCategory); + +var insertAmount = insert.CreateParameter(); +insertAmount.ParameterName = "@amount"; +insertAmount.DbType = DbType.Double; +insert.Parameters.Add(insertAmount); + +insert.Prepare(); + +var sw = Stopwatch.StartNew(); +for (var i = 1; i <= RowCount; i++) +{ + insertId.Value = i; + insertCategory.Value = "cat-" + (i % 20); + insertAmount.Value = i * 1.25; + insert.ExecuteNonQuery(); +} + +insertTx.Commit(); +connection.Checkpoint(); +Console.WriteLine($"insert+checkpoint: {sw.Elapsed}"); + +using var read = connection.CreateCommand(); +read.CommandText = "SELECT amount FROM events WHERE id = @id"; +var readId = read.CreateParameter(); +readId.ParameterName = "@id"; +readId.DbType = DbType.Int64; +read.Parameters.Add(readId); +read.Prepare(); + +var checksum = 0.0; +sw.Restart(); +for (var i = 0; i < ReadCount; i++) +{ + readId.Value = (i % RowCount) + 1; + checksum += Convert.ToDouble(read.ExecuteScalar()); +} + +Console.WriteLine($"point reads: {sw.Elapsed}; checksum={checksum:0.00}"); + +var plan = connection.ExplainQuery( + "SELECT amount FROM events WHERE id = @id", + analyze: true); +Console.WriteLine(plan.Text); +``` + +Run it in Release mode: + +```bash +dotnet run -c Release +``` + +For the lowest-overhead microbenchmarks or import tools, use the native +`DecentDB.Native.DecentDB` surface directly. Native prepared statements are +reusable, but each repeated execution must reset the cursor and clear old +bindings unless you use a fused `Rebind*Execute` or `ExecuteBatch*` helper: + +```csharp +using var db = new DecentDB.Native.DecentDB("/path/to/app.ddb"); +using var stmt = db.Prepare("UPDATE counters SET value = value + 1 WHERE id = $1"); + +stmt.BindInt64(1, 1).StepRowsAffected(); + +foreach (var id in ids) +{ + stmt.RebindInt64Execute(id); +} +``` + +Native fused and batch helpers are useful for import tools and microbenchmarks, +but they are a lower-level surface than ADO.NET. They still require explicit +statement lifetime management, correct reset/binding behavior, and benchmark +coverage that matches the application's transaction and durability settings. + +### Query diagnostics + +Use `ExplainQuery` before attributing a slow query to binding overhead: + +```csharp +var plan = connection.ExplainQuery( + "SELECT id, email FROM users WHERE id = @id", + analyze: true); + +Console.WriteLine(plan.Text); ``` -`PersistentPkIndex = true` can improve some reopened primary-key lookup patterns, -but it adds write-time and file-size overhead. Benchmark it with your workload -before enabling it globally; it is not part of the default embedded-fast profile. +`EXPLAIN` and `EXPLAIN ANALYZE` currently support `SELECT` queries. For UPDATE +or DELETE performance, first verify that an equivalent SELECT predicate uses the +expected index, then benchmark the mutation separately. + +### Benchmarking against SQLite from .NET + +For useful DecentDB-vs-SQLite measurements: + +- run Release builds, warm up the JIT, repeat each case, and alternate engine + order between runs +- use the same transaction boundaries and checkpoint both engines at the same + logical boundaries +- keep durability modes honest; do not compare DecentDB's durable default + against SQLite `synchronous = NORMAL` or `OFF` without labeling the result as + a relaxed-durability comparison +- reuse prepared `DbCommand` instances for both providers in hot loops +- do not allocate a new command and new parameter objects for every row in an + insert, update, delete, or point-read loop +- time materialized-summary maintenance if the measured query reads a summary + table instead of doing the original aggregate +- verify that search patterns return comparable rows; no-result LIKE queries + mostly measure planning and binding overhead +- add indexes that match the tested predicate and ordering, for example + `(paid, total)` for `WHERE paid = FALSE AND total < @max` or `due_at DESC` + for `ORDER BY due_at DESC LIMIT 1000` +- compare query plans before drawing planner conclusions The in-tree `DecentDb.ShowCase` sample includes a `PERFORMANCE PATTERNS` -section, but it should be read as a sanity-check aid rather than a benchmark -suite. The current showcase and tests intentionally focus on: - -- projection vs tracked reads -- `AsNoTracking()` for read-mostly paths -- `AsSplitQuery()` over included relationship graphs -- keyset-style paging -- async materialization vs `AsAsyncEnumerable()` result ordering -- bulk update/delete rowcount sanity - -These checks are meant to catch obviously pathological provider behavior and to -teach reasonable defaults for embedded workloads. They are not claims of -cross-provider performance parity. +section, but it is a sanity-check aid rather than a benchmark suite. The +`bindings/dotnet/benchmarks` projects are better starting points for measuring +provider overhead, native fast paths, point reads, scans, and SQLite comparison +cases. The canonical CRM comparison benchmark lives at +[`bindings/dotnet/benchmarks/DecentDB.CrmComparison/`](../../bindings/dotnet/benchmarks/DecentDB.CrmComparison/). ## Build the native library diff --git a/docs/user-guide/benchmarks.md b/docs/user-guide/benchmarks.md index f270c6bf..b933bb6b 100644 --- a/docs/user-guide/benchmarks.md +++ b/docs/user-guide/benchmarks.md @@ -13,7 +13,7 @@ This page collects the current Python embedded comparison charts and a plain-lan | Engine | Version stamp | Source | | --- | --- | --- | -| DecentDB | 2.15.0 | Workspace package version | +| DecentDB | 2.16.0 | Workspace package version | | SQLite (`SQLite_wal_full`) | 3.52.0 | Benchmark-reported engine version | | DuckDB | 1.5.1 | Benchmark-reported engine version | | H2 (`JDBC`) | 2.2.224 | Benchmark-reported engine version | diff --git a/include/decentdb.h b/include/decentdb.h index 928901e5..2892ff20 100644 --- a/include/decentdb.h +++ b/include/decentdb.h @@ -341,8 +341,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/tests/bindings/dart/pubspec.lock b/tests/bindings/dart/pubspec.lock index 62e252a4..34ff9fe0 100644 --- a/tests/bindings/dart/pubspec.lock +++ b/tests/bindings/dart/pubspec.lock @@ -7,7 +7,7 @@ packages: path: "../../../bindings/dart/dart" relative: true source: path - version: "2.15.0" + version: "2.16.0" ffi: dependency: "direct main" description: