diff --git a/.github/workflows/perf-guardrails.yml b/.github/workflows/perf-guardrails.yml index 9881d697..985884e8 100644 --- a/.github/workflows/perf-guardrails.yml +++ b/.github/workflows/perf-guardrails.yml @@ -5,72 +5,120 @@ on: paths: - "src/**" - "tests/CSharpDB.Benchmarks/**" + - "tests/CSharpDB.Benchmarks.Tests/**" + - "global.json" + - "**/*.props" + - "**/*.targets" + - "**/*.csproj" + - "**/packages.lock.json" + - "**/packages.config" + - "**/[Nn]u[Gg]et.[Cc]onfig" + - ".config/dotnet-tools.json" - ".github/workflows/perf-guardrails.yml" workflow_dispatch: inputs: fail_on_regression: - description: "Fail run when guardrails are violated" + description: "Fail timing regressions (evidence integrity always enforced)" required: false default: false type: boolean - baseline_snapshot: - description: "Optional baseline snapshot folder name (for example 20260302-001757)" + base_ref: + description: "Product base ref (default: head parent; PRs use the PR base SHA)" required: false default: "" type: string schedule: - cron: "0 10 * * *" +permissions: + contents: read + jobs: perf-guardrails: runs-on: windows-latest - timeout-minutes: 240 - + timeout-minutes: 360 steps: - - name: Checkout + - name: Checkout harness uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 2 + + - name: Resolve product revisions + id: revisions + shell: pwsh + env: + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + REQUESTED_BASE_REF: ${{ inputs.base_ref }} + run: | + $head = (git rev-parse HEAD).Trim() + if ($LASTEXITCODE -ne 0) { throw 'Could not resolve head.' } + if ($env:PR_BASE_SHA) { $base = $env:PR_BASE_SHA } + elseif ($env:REQUESTED_BASE_REF) { + git fetch --no-tags origin $env:REQUESTED_BASE_REF + if ($LASTEXITCODE -ne 0) { throw 'Could not fetch requested base.' } + $base = (git rev-parse FETCH_HEAD).Trim() + } + else { $base = (git rev-parse HEAD^).Trim() } + if ($LASTEXITCODE -ne 0 -or $base -notmatch '^[a-f0-9]{40}$') { throw 'Could not resolve base.' } + "base=$base" >> $env:GITHUB_OUTPUT + "head=$head" >> $env:GITHUB_OUTPUT + + - name: Checkout base product + uses: actions/checkout@v7 + with: + ref: ${{ steps.revisions.outputs.base }} + path: .tmp/perf-guardrails/base - - name: Setup .NET + - name: Checkout head product + uses: actions/checkout@v7 + with: + ref: ${{ steps.revisions.outputs.head }} + path: .tmp/perf-guardrails/head + + - name: Setup head .NET SDK uses: actions/setup-dotnet@v6 with: global-json-file: global.json - - name: Run Perf Guardrails PR Lane (Report) - if: github.event_name == 'pull_request' - shell: pwsh - run: | - pwsh ./tests/CSharpDB.Benchmarks/scripts/Run-Perf-Guardrails.ps1 ` - -Configuration Release ` - -Mode pr ` - -NoFailOnRegression ` - -BaselineSnapshot "${{ inputs.baseline_snapshot }}" + - name: Setup base .NET SDK + uses: actions/setup-dotnet@v6 + with: + global-json-file: .tmp/perf-guardrails/base/global.json - - name: Run Perf Guardrails Release Lane (Report) - if: github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || inputs.fail_on_regression != true) + - name: Test guardrail integrity contract shell: pwsh - run: | - pwsh ./tests/CSharpDB.Benchmarks/scripts/Run-Perf-Guardrails.ps1 ` - -Configuration Release ` - -Mode release ` - -NoFailOnRegression ` - -BaselineSnapshot "${{ inputs.baseline_snapshot }}" + run: ./tests/CSharpDB.Benchmarks/scripts/Test-PerfGuardrails.ps1 - - name: Run Perf Guardrails Release Lane (Gate) - if: github.event_name == 'workflow_dispatch' && inputs.fail_on_regression == true + - name: Run balanced base and head guardrails shell: pwsh + env: + PERF_EVENT: ${{ github.event_name }} + FAIL_ON_REGRESSION: ${{ inputs.fail_on_regression }} run: | - pwsh ./tests/CSharpDB.Benchmarks/scripts/Run-Perf-Guardrails.ps1 ` - -Configuration Release ` - -Mode release ` - -BaselineSnapshot "${{ inputs.baseline_snapshot }}" + $mode = if ($env:PERF_EVENT -eq 'pull_request') { 'pr' } else { 'release' } + $reportOnly = $env:FAIL_ON_REGRESSION -ne 'true' + ./tests/CSharpDB.Benchmarks/scripts/Run-PairedPerfGuardrails.ps1 ` + -BaseRoot .tmp/perf-guardrails/base ` + -HeadRoot .tmp/perf-guardrails/head ` + -OutputPath .tmp/perf-guardrails/evidence ` + -Configuration Release -Mode $mode -NoFailOnRegression:$reportOnly ` + -MicroArguments @('--warmupCount', '2', '--iterationCount', '3', '--iterationTime', '100', '--buildTimeout', '600') - name: Upload Perf Artifacts if: always() uses: actions/upload-artifact@v7 with: name: perf-guardrails-${{ github.run_id }} + if-no-files-found: error + include-hidden-files: true path: | - tests/CSharpDB.Benchmarks/results/perf-guardrails-last.md - tests/CSharpDB.Benchmarks/results/perf-guardrails-run-logs/*.log - BenchmarkDotNet.Artifacts/results/*.csv - BenchmarkDotNet.Artifacts/*.log + .tmp/perf-guardrails/evidence/ + BenchmarkDotNet.Artifacts/ + tests/CSharpDB.Benchmarks/BenchmarkDotNet.Artifacts/ + .tmp/perf-guardrails/base/BenchmarkDotNet.Artifacts/ + .tmp/perf-guardrails/base/tests/CSharpDB.Benchmarks/BenchmarkDotNet.Artifacts/ + .tmp/perf-guardrails/head/BenchmarkDotNet.Artifacts/ + .tmp/perf-guardrails/head/tests/CSharpDB.Benchmarks/BenchmarkDotNet.Artifacts/ + .tmp/perf-guardrails/base/tests/CSharpDB.Benchmarks/results/perf-guardrails-run-logs/ + .tmp/perf-guardrails/head/tests/CSharpDB.Benchmarks/results/perf-guardrails-run-logs/ diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index aef548ea..7aaf4920 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,47 +1,45 @@ # What's New -## CSharpDB 4.6.5 - -CSharpDB 4.6.5 adds a Privacy & Retention workflow in Studio, opens the -Storage Inspector without an initial full-file scan, and improves SQL editor -completion and storage write paths. These notes cover changes since v4.6.4. - -### Privacy & Retention - -- Create and save reusable policies that select records with typed conditions, - date rules, and explicitly mapped related records. Search, duplicate, import, - and export policies in **Data Hygiene → Privacy & Retention**. -- Choose fields to erase, replace with a constant or stable anonymous value, or - partially mask. Preview eligible records, changed counts, preserved fields, - warnings, and bounded before/after samples before applying a policy. -- Apply a prepared policy in one transaction with bounded batches and - cancellation before commit. Review committed run receipts and reconcile an - uncertain commit outcome before retrying. -- Protect keys and relationship columns, reject unsupported constraints or - mutation callbacks during preparation, and keep records and their declared - relationships intact. - -### Studio and storage improvements - -- Open Storage Inspector with a persisted summary. Full page and WAL analysis - now starts only when **Analyze storage** is requested, with cancellation. -- Restore context-aware SQL editor suggestions for tables, columns, and - keywords, and expand the supported keyword completion set. -- Reuse WAL snapshot segments and share immutable snapshot maps to reduce - copying during concurrent reads. Streamline simple INSERT execution and - checkpoint storage paths. -- Refresh the .NET SDK and selected runtime, tooling, and test dependencies. - -### Compatibility and scope - -- No database file-format revision is introduced by these changes. -- The initial Privacy & Retention workflow supports a single standard - file-backed direct database, including durable hybrid storage. Remote, - sharded, memory-only, and snapshot-only hybrid execution are outside its - scope. It changes selected live fields; it does not delete records, discover - unselected personal-data copies, or establish that a person is unidentifiable. -- Privacy runs require an exclusive writable file session. Other writable - handles can prevent preparation or apply from starting. Review the policy, - preview, and run outcome before retrying a failed or uncertain operation. -- Storage analysis remains an explicit potentially long-running operation. - The initial summary does not include results of a full integrity scan. +## CSharpDB 4.6.6 + +CSharpDB 4.6.6 improves durable write throughput, prepared queries, and bounded +primary-key reads while strengthening reader snapshot, buffered-row, and concurrent +WAL-read correctness. +These notes cover changes since v4.6.5. + +### Query and write improvements + +- Reuse prepared SELECT plans across parameter values and support bounded integer + primary-key ranges through pooled readers and ordinary queries. +- Preserve reader catalog snapshots across commits and checkpoints, including + schema metadata used by existing readers. +- Reduce table-scan, LIMIT, snapshot-pager, and leaf-prefetch allocations. Forward + intact row batches while preserving ownership and mixed row/batch traversal. +- Streamline primary-key mutations and INSERT execution by reusing column ordinals, + avoiding per-row observer allocations, and skipping redundant canonical value + conversions. +- Reduce durable-write checkpoint stalls and improve WAL/checkpoint lifecycle + handling and storage-page ownership. + +### Storage correctness + +- Prevent checkpoint reset or retained-tail compaction from invalidating WAL + offsets used during write-conflict resolution. This fixes a concurrency race + that could cause short WAL reads or return incorrect page contents. +- Defer checkpoint finalization while durable commits still have unpublished WAL + offsets, preserving correct reads after those commits complete. +- Add deterministic regression coverage for reset and compaction, including stale + offsets that remain within file bounds, cancellation, lock release, and forward + progress. The database and WAL formats are unchanged. + +### Dependencies and maintenance + +- Update gRPC/Protobuf, SQL Server migration dependencies, WebView2, OpenTelemetry, + Scalar, and test tooling. +- Retain Microsoft.OpenApi 2.12.0 for compatibility with + Microsoft.AspNetCore.OpenApi 10.0.12 on .NET 10. + +### Performance guardrails + +- Validate performance evidence integrity and reject incomplete or inconsistent + comparison evidence. diff --git a/deploy/migration-tool/posix/install-csharpdb-migration-tool.sh b/deploy/migration-tool/posix/install-csharpdb-migration-tool.sh index b0795fd6..abe51885 100644 --- a/deploy/migration-tool/posix/install-csharpdb-migration-tool.sh +++ b/deploy/migration-tool/posix/install-csharpdb-migration-tool.sh @@ -74,7 +74,7 @@ for REQUIRED_FILE in \ README.md \ adapters/sqlserver/csharpdb-migration-sqlserver-worker \ adapters/sqlserver/THIRD-PARTY-NOTICES.md \ - adapters/sqlserver/licenses/Microsoft.Data.SqlClient.SNI.runtime-6.0.3-LICENSE.txt \ + adapters/sqlserver/licenses/Microsoft.Data.SqlClient.SNI.runtime-7.1.0-LICENSE.txt \ adapters/mysql/csharpdb-migration-mysql-worker \ adapters/mysql/THIRD-PARTY-NOTICES.md do diff --git a/deploy/migration-tool/windows/install-csharpdb-migration-tool.ps1 b/deploy/migration-tool/windows/install-csharpdb-migration-tool.ps1 index bd2bd5d4..59cc641a 100644 --- a/deploy/migration-tool/windows/install-csharpdb-migration-tool.ps1 +++ b/deploy/migration-tool/windows/install-csharpdb-migration-tool.ps1 @@ -173,7 +173,7 @@ $requiredFiles = @( (Join-Path $sourceRoot 'README.md'), (Join-Path $sourceRoot 'adapters/sqlserver/csharpdb-migration-sqlserver-worker.exe'), (Join-Path $sourceRoot 'adapters/sqlserver/THIRD-PARTY-NOTICES.md'), - (Join-Path $sourceRoot 'adapters/sqlserver/licenses/Microsoft.Data.SqlClient.SNI.runtime-6.0.3-LICENSE.txt'), + (Join-Path $sourceRoot 'adapters/sqlserver/licenses/Microsoft.Data.SqlClient.SNI.runtime-7.1.0-LICENSE.txt'), (Join-Path $sourceRoot 'adapters/mysql/csharpdb-migration-mysql-worker.exe'), (Join-Path $sourceRoot 'adapters/mysql/THIRD-PARTY-NOTICES.md'), (Join-Path $sourceRoot 'adapters/access/csharpdb-migration-access-worker.exe'), diff --git a/docs/README.md b/docs/README.md index d652a470..d3bf7594 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,6 +4,8 @@ - [Public user guides](../www/docs/index.html) - [Release notes](../RELEASE_NOTES.md) - [Changelog and downloads maintenance](releases/changelog-maintenance.md) +- [Performance checkpoint: October 6, 2026](design/performance-checkpoint-2026-10-06.md) +- [Draft WAL recovery boundary and bounded reclamation](design/wal-reclamation.md) The website source is in `www/`. Shared documentation images are in `docs/images/`. The release publication inventories in `docs/releases/` support the documentation validation scripts. diff --git a/docs/design/performance-checkpoint-2026-10-06.json b/docs/design/performance-checkpoint-2026-10-06.json new file mode 100644 index 00000000..36dcef0a --- /dev/null +++ b/docs/design/performance-checkpoint-2026-10-06.json @@ -0,0 +1,129 @@ +{ + "schemaVersion": 1, + "date": "2026-10-06", + "productCommit": "860d7ee6ee1e0e0d4596befdc98ed06a9abc22b6", + "qualification": { + "engine-full": { + "counts": { + "total": 3478, + "passed": 3478, + "failed": 0, + "skipped": 0, + "errors": 0 + }, + "resultSha256": "C06F56F84BFD7EC82FE101BB883BD94D6532D03703730CB9852C8991EAC18FCF" + }, + "ado-full": { + "counts": { + "total": 325, + "passed": 325, + "failed": 0, + "skipped": 0, + "errors": 0 + }, + "resultSha256": "AC11B65A336AE242F5CE31F1F0688CA3173E00F8BB4C9F39B49A35AF74113990" + } + }, + "productDllSha256": { + "CSharpDB.Client.dll": "2F41A36768E3EC95E7BF1EA7728BDD4C431FB60F4863DF71A353FA28751704AF", + "CSharpDB.Data.dll": "CB885B7BF80312D537F3B06025F58CEF47F54270659B39971228B0E5783EDAEB", + "CSharpDB.Engine.dll": "105C85149AF8DD8B1B72AEB318CB570172DA221A26DA346491EA294100393B77", + "CSharpDB.Execution.dll": "56EDDACA5F96997C83946648AAF8CA64F00FCC46E21C4010CFB805E5542B34CF", + "CSharpDB.ImportExport.dll": "F2116EC948AF5BC167B5C0E98778ED5BD7CC36C0F7FD0A1823324D79482A9ED8", + "CSharpDB.Observability.dll": "E0E9AFA8CB58C8BE13B363166419237DB6D094A4024E2E48621DFF118527EE5D", + "CSharpDB.Pipelines.dll": "267E60A53FB190726BD8A8241F244DB086DBD19C1253D7BB45126C411CB0F5E0", + "CSharpDB.Primitives.dll": "DFCDD004EE894741FE023BAAFDE270C81621FB8C39D0D8D0D2A664088DA5CBAC", + "CSharpDB.Sql.dll": "1B0AB2931AE835F316428A0561AAD36F4E5857B0D19EF2A1461CF490BFE37AEA", + "CSharpDB.Storage.Diagnostics.dll": "B7621FD40BED4C2838DBBEF5B6C6CDF9C6BB798C31BD69C1DC6758A6A1CA1BB5", + "CSharpDB.Storage.dll": "5E5CD80A781E37A0324EAEF2C1273ECEA70E885252E74F53B012CB0CED314488" + }, + "walCheckpointRegressionSha256": "DB5AC28DFA886233C57AE29F5E6A3937DE95D5B8295A708EDFCDDC54C8718F2A", + "prototype": { + "passed": 179, + "resultSha256": "D5C65B36EBC3AD9113311B0542B0C304549433FECCFA022B01A9E0EDB4C6820F", + "sourceSha256": { + "tests/CSharpDB.WalPrototype.Tests/CommittedTailCrashTests.cs": "7DE1FA8410907AA4FF0CE2288FAD30775BFD12BFB8BF75016832B03C91541B41", + "tests/CSharpDB.WalPrototype.Tests/CommittedTailTests.cs": "27F270A36172C5D1AC91D395ED8AC7B49A9C9513067CF24F937238E2A85F8104", + "tests/CSharpDB.WalPrototype.Tests/ControlCodecTests.cs": "100D7BB86227A0C230E2BAE0586953CBFABD3E8734111751641356E2074E1176", + "tests/CSharpDB.WalPrototype.Tests/ControlProtocol.cs": "63986B783C8F7802C58783A017C74FEBFB607AB981992569DCA850AEB5E4161D", + "tests/CSharpDB.WalPrototype.Tests/ControlProtocolTests.cs": "8B8E8F7F3D6592087BE52D446106E799D5B7D001CE5998345EB7DBEC019425FE", + "tests/CSharpDB.WalPrototype.Tests/ControlRecord.cs": "173CE24E9AFD22528B936495E58034CC0E8E624D46A92623194E359768173418", + "tests/CSharpDB.WalPrototype.Tests/CSharpDB.WalPrototype.Tests.csproj": "27B769940AE014F51AB3F88BD8D357C57AA2AA15851F8855529A8FAE6B7BA303", + "tests/CSharpDB.WalPrototype.Tests/ExperimentalWalFile.cs": "C9725A8843FC79CFC8FA5E117DCFB57921A9F13B23C81428B91132B9242BBB06", + "tests/CSharpDB.WalPrototype.Tests/ExperimentalWalStore.cs": "B8EFBFF9ADD205AD144899C046D50542DC03EC3692AAE0BAD4D93DD102ED137E", + "tests/CSharpDB.WalPrototype.Tests/FaultHarness.cs": "2C41BD811887771155321372BD8BEDBBDA44C4DEDEC311B31845DE7911E418E9", + "tests/CSharpDB.WalPrototype.Tests/WalFrameCodec.cs": "4BF10DDC191073C64E7AAF9903E0F4DE45D4FE897058BF26DC48B4502461F258", + "tests/CSharpDB.WalPrototype.Tests/WalProcessCrashTests.cs": "EC8206F67BF3F0FB950B0554CBDFD759F0C14C611DE675C1CD52E702DE096A45", + "tests/CSharpDB.WalPrototype.Tests/WalStorageTests.cs": "BFD03B9A9610E55F4A230C52FCB76C6A51315E5BD70F536221CEE3706AF28EDE", + "tests/CSharpDB.WalPrototype.CrashHarness/CSharpDB.WalPrototype.CrashHarness.csproj": "8047E7B9E06BCFA3BE1F7D2A73CEF5C0D930FF8423474C8500F4C0AE8C3179DD", + "tests/CSharpDB.WalPrototype.CrashHarness/Program.cs": "F5B823188B1EC3805C51397F4783F9070CD7CC89D17FBA0736AD643F1E30E4AC" + } + }, + "boundaryModel": { + "cases": 1740, + "directory": ".tmp/wal-boundary-design-20261005", + "sourceSha256": "22503D0F6C11FF9451B1DED3C923320D3B4FA8D8D98A7DB41B5D02D4F1D523DE", + "resultSha256": "7EBEA5545EEE4D8F73C73CAEF6F331F411437DED4209441071217382D8B1CAF1", + "verification": "Source and model-results-v2.json hashes match the original closeout. The older manifest also names an evolving workspace design document, so it is not included among the fully reverified frozen study directories." + }, + "localEvidence": [ + { + "directory": ".tmp/btree-common-path-profile-20261006", + "files": 493, + "manifestSha256": "FCB37630E82857400B2D398CCA6E260528A894009DC381055797E32A6B3C02C5" + }, + { + "directory": ".tmp/shared-hint-advancing-update-20261006", + "files": 385, + "manifestSha256": "1E9ED296AA2CE6CFE4170E1EF3CB2BCC2113504DC96E3C9EB2463A66F5D98C73" + }, + { + "directory": ".tmp/rowid-bookkeeping-profile-20261006", + "files": 392, + "manifestSha256": "5FAB98B941A2ED793EFC29263DA72B30DD88F0EB3A148DCD3ECE73A413B1D5EA" + }, + { + "directory": ".tmp/rowid-cache-single-lookup-20261006", + "files": 292, + "manifestSha256": "ECD72796AF997A06AD4D16DFCDEE668D5CD4BFE35247DDF256A6E40E9B4E3F81" + }, + { + "directory": ".tmp/deferred-snapshot-mapping-20261006", + "files": 3272, + "manifestSha256": "6717D996D8A2785017A35F8CFAC4F2C7E34A8AF5B89567F3C8252E94DE92217D" + }, + { + "directory": ".tmp/mapped-short-auto-checkpoint-20261006", + "files": 183, + "manifestSha256": "9695B3ED55C2A9BA1CFD4850BF23FD5859265E3BB98D454DE7154620C64396E0" + }, + { + "directory": ".tmp/combined-validation-20261006", + "files": 428, + "manifestSha256": "F2CBB60A4425E04DD3412C78D4A78AAD60096D0446A998A4D583ED03267AE595" + }, + { + "directory": ".tmp/insert-profile-refresh-20261006", + "files": 368, + "manifestSha256": "6F100955BCF27EDD40BEB85C7D5E049940829C278517D345286168D0FC02CFA2" + }, + { + "directory": ".tmp/assignment-coercion-fastpath-20261006", + "files": 436, + "manifestSha256": "3C58D33D2775D57F64D9B7B0587B1328FF6257AFB2F3A0222036F90DE8E7F0E0" + }, + { + "directory": ".tmp/wal-commit-boundary-20261006", + "files": 9500, + "manifestSha256": "02FE4CC1829693144FB0482D195E6FEDDB86F50EF0489D0F99AC7D858C9B6282" + } + ], + "retention": { + "date": "2026-10-06", + "status": "raw-data-and-captured-binaries-discarded-at-user-request", + "primaryResultReportsRetained": true, + "fullArtifactReverificationAvailable": false, + "deletionReport": ".tmp/cleanup-20261006/retained-data-deletion.json" + }, + "limitations": "The hashes record successful verification at the original checkpoint. A later user-requested cleanup on October 6, 2026 removed bulky databases, traces, per-event captures and captured binaries. Compact result tables, summaries, source records and test logs remain. Original manifests are historical receipts and no longer describe complete local bundles. No tests or performance workloads were repeated." +} diff --git a/docs/design/performance-checkpoint-2026-10-06.md b/docs/design/performance-checkpoint-2026-10-06.md new file mode 100644 index 00000000..e46cab00 --- /dev/null +++ b/docs/design/performance-checkpoint-2026-10-06.md @@ -0,0 +1,135 @@ +# Performance checkpoint: October 6, 2026 + +This is a historical checkpoint for product commit `860d7ee6`, not qualification +of the current branch head. Subsequent v4.6.6 changes fixed checkpoint finalization +racing with write-conflict WAL reads and removed the recorded xUnit1069 warning. +The original results and evidence identities below remain unchanged. The added +finalization synchronization has not received a new performance qualification. +See the [current release notes](../../RELEASE_NOTES.md) and +[checkpoint coordination](../../src/CSharpDB.Storage/README.md#checkpoint-coordination). + +The current performance pass is complete. Product code at `860d7ee6` retains +the adopted improvements and its 3,803-test correctness qualification. The +combined representative comparison passed its declared local performance +screen. The remaining investigated candidates are rejected or parked; the +latest B-tree diagnostic does not justify another product change. + +This checkpoint collects the research history, WAL design/model/prototype, +and two production recovery regression cases. It introduces no product code, +WAL format, default-setting or release-policy change. The separate website +roadmap edits are outside this checkpoint. + +## Adopted product state + +The product anchor is `860d7ee6ee1e0e0d4596befdc98ed06a9abc22b6` on +`version.4.6.6`. The detailed sequence and individual acceptance results remain +in [benchmark history](../../tests/CSharpDB.Benchmarks/HISTORY.md). + +| Area | Retained changes and commit anchors | +|---|---| +| Prepared readers | Prepared plan reuse (`ebf0257e`), catalog snapshot correctness (`0dc0a36f`), bounded prepared ranges (`fcdfc7ea`), bounded read-ahead and planner work (`b0de2b6f`, `9af89853`) | +| Scans and batches | Avoid write-only snapshot allocations (`f82ed0b6`), allocate scan batches on demand (`d55c5d98`), forward intact LIMIT batches and correct mixed traversal (`92716dfa`, `30c3a01c`), reuse successful prefetch cancellation state (`f94ce04f`) | +| Mutations and ownership | Primary-key mutation improvements with page ownership preserved (`3156badc`), remove per-row observer allocations (`4e59bebf`) | +| Insert setup | Reuse statement column ordinals (`19ffabe5`), skip redundant canonical assignment conversions (`860d7ee6`) | + +The final Release qualification passed **3,478 engine and 325 ADO tests**, with +no failures or skips. The recorded build has one existing xUnit1069 warning in +`PooledMemoryMappedSnapshotTests.cs:14`. Qualification output and the eleven +product DLL hashes are retained in +`.tmp/assignment-coercion-fastpath-20261006/qualification.json`. +The checkpoint audit checks these identities and the retained test results; +it does not repeat the unchanged product suite or performance workloads. + +## Combined local result + +Exact frozen `0dc0a36f` and `860d7ee6` binaries ran common harnesses in balanced +serial order under normal desktop use. All **14 declared cases passed**, with +30,800 measured durable commits and 183,600 query/cycle operations. Checks +covered row values, checksums, durable reopen, reader disposal and frozen inputs. +There were no measured retries or sample exclusions. + +| Representative case | Current versus baseline, both balanced pairs | +|---|---| +| 10,000-row insertion phase median | 18.49% / 18.10% lower | +| 10,000-row write throughput including final drain | 12.06% / 11.92% higher | +| Four-row prepared range, one-table catalog, P50 | 53.56% / 55.22% lower | +| 4,096-row prepared range, one-table catalog, P50 | 21.60% / 22.71% lower | +| Sequential four-row UPDATE/commit plus pooled read, P50 | 70.45% / 69.34% lower | + +Full paired P50/P95/P99, allocations, maxima and guard results are in +`.tmp/combined-validation-20261006/analysis-r2/Findings.md` and `analysis.json`. +The one-row write median changed +0.23% / +0.67%; the 1,000-row write P99 changed ++16.23% / -10.38%. Passing the declared screen does not mean every metric improved. + +These are local representative results, not release qualification or published +benchmark-table replacements. The baseline already includes prepared-plan reuse +and checkpoint optimization, so this comparison does not measure those earlier +changes. Read fixtures have 10,000 rows and 1/256-table catalogs; mapping is off. +The mixed case is sequential, not many-client concurrency. The baseline's older +blocked performance qualification and earlier larger/concurrent studies retain +their own outcomes. + +## Closed and parked investigations + +| Investigation | Outcome and reason | +|---|---| +| Dirty-page callback allocation | Rejected: allocation falls 1.41%, but median paired throughput including drain falls 0.44%. | +| Shared row-ID advancing update | Rejected: insertion median improvements of 0.71% / 3.62% miss the required 3% in both pairs. Existing synchronization remains. | +| Local row-ID cache single lookup | Rejected: +0.11% / -1.45% insertion median changes miss the same target. | +| Known-key right-edge hint | Rejected: 0.15% / 2.11% insertion median improvements miss the 3% target. | +| Mapping with default automatic checkpoints | Rejected as a default change: standalone range gains accompany mixed point/range/cycle regressions. Mapping stays opt-in. | +| Deferred snapshot mapping | Rejected: mixed ranges slow by 2.52-6.49%, with additional tail/allocation failures. | +| Controlled durable-flush cadence | Parked: the pacing screen fails its tail target and reduces throughput by about 79%. Flush-path attribution does not identify a device or scheduler cause. | +| Larger WAL compaction buffers and read prefetch | Rejected; buffer-size tuning is parked. The 256-frame variant misses its P99 target and exceeds allocation limits. | +| Deferred logical WAL retirement shortcut | Rejected as unsafe: the corrupted-prefix model loses a later committed page; the current implementation preserves it. | +| Mirrored committed-tail WAL prototype | Correctness reference only: 179 tests pass, but three flushes per commit produce 2.61-3.45 times the floor-only prototype's P99 and 66.2-68.6% lower throughput. No production adoption. | +| Common B-tree insertion attribution | Diagnostic complete; no candidate selected. Small intervals overlap timer calibration, and dirty-page/buffer ownership cannot safely be bypassed from these results. | + +The chronological history preserves each study's original next-step wording. +This table records the final disposition after those follow-ups. Rejected +patches and failed setup attempts remain in their evidence directories. +The first ordinal screen was rejected before the corrected version was adopted; +the observer allocation change was adopted after a separate fixed confirmation. +Their earlier failed screens remain part of the history. + +## WAL research retained for review + +The [WAL design](wal-reclamation.md) describes the isolated fresh-file `CWA2` +format, its limitations, and direct build/run instructions. The +[boundary model](wal_boundary_model.py) passed 1,740 bounded cases; its ordered +flush assumptions do not establish real power-loss durability. + +The two `CSharpDB.WalPrototype.*` projects remain outside the product solution. +Their retained 179-test run includes modeled fault images and 90 targeted child +process terminations with the OS running. The floor-only mode can fail to reopen +after a torn append; the opt-in committed-tail mode addresses that bounded +recovery problem but fails the cost screen. Neither mode qualifies production +integration, migration, many-reader behavior or storage-device flush guarantees. + +The retained +[checkpoint regressions](../../tests/CSharpDB.Tests/WalCheckpointBatchTests.cs) +verify that discarding an invalid indexed or superseded copied prefix preserves +a later committed suffix after reopen. Both cases are included in the final +engine qualification. + +## Evidence and resumption + +The [evidence inventory](performance-checkpoint-2026-10-06.json) records product +identity, result hashes, prototype source hashes and historical study manifests. +At the original checkpoint, raw captures, frozen binaries and crash fixtures +remained local under ignored `.tmp/` directories. On October 6, 2026, a later +user-requested cleanup removed the bulky databases, traces, per-event captures +and captured binaries. Compact result tables, summaries, source records and +test logs remain available. The original manifests are historical receipts; +complete artifact re-verification is no longer available for the pruned bundles. +Deletion details are recorded in +`.tmp/cleanup-20261006/retained-data-deletion.json` and its per-file journal. +This Git checkpoint preserves source and conclusions and does not back up raw +artifacts or make them available in a fresh clone. +Historical `committed: false` receipts describe the end of each study and remain +unchanged by this later checkpoint. + +Resume with a distinct workload or mechanism and a fixed correctness/performance +gate. Do not reopen rejected candidates by relaxing thresholds, retrying for a +better sample or inferring savings from instrumented elapsed shares. Any new +product candidate needs independent uninstrumented evidence before adoption. diff --git a/docs/design/wal-reclamation.md b/docs/design/wal-reclamation.md new file mode 100644 index 00000000..2370989f --- /dev/null +++ b/docs/design/wal-reclamation.md @@ -0,0 +1,516 @@ +# Draft WAL recovery boundary and bounded reclamation + +Use an explicit durable recovery boundary to retire checkpointed WAL bytes, +then reclaim the file through a quiescent full drain and generation reset. +Two independently checksummed control records protect the boundary. This is a +proposal for an isolated prototype; the production WAL format and behavior +are not changed by this prototype. No performance improvement has been established. + +Production v4.6.6 separately fixes checkpoint finalization racing with +write-conflict WAL reads without changing either file format. Finalization takes +the shared commit mutex before the begin/writer barriers and checkpoint admission, +and defers while durable commits own unpublished offsets. Any future integration +of this proposal must preserve that coordination. See the +[storage guide](../../src/CSharpDB.Storage/README.md#checkpoint-coordination). + +The immediate target is the recurring cost of relocating a retained suffix +after an incremental checkpoint. Current measurements show approximately +10.46 MiB moved through 167 read/write pairs per affected checkpoint. Simply +removing relocation is unsafe: an invalid indexed or superseded prefix frame +can stop recovery before a newer committed suffix. The current implementation +passes both cases. See the [benchmark history](../../tests/CSharpDB.Benchmarks/HISTORY.md) +and [recovery regressions](../../tests/CSharpDB.Tests/WalCheckpointBatchTests.cs). + +## Proposed format and compatibility + +Keep the database format unchanged for the initial prototype. Use a distinct +WAL magic, tentatively `CWA2`, with two 4,096-byte control slots at offsets 0 and +4,096. Frame data starts at `H = 8,192`. Retain the existing 4,120-byte frame +shape initially; its salt pair identifies the current WAL generation. These +are prototype choices, not a published format specification. + +Each slot contains the magic, format version, recognized feature flags, page +and frame sizes, data start, file identity, monotonically increasing sequence, +epoch, generation salts, mode, replay floor and checkpoint page count. The +replay floor is an absolute physical offset aligned relative to `H`. A SHA-256 +digest covers the complete slot except its digest field, including reserved +bytes. The isolated codec below specifies and tests these fields; this remains +an experimental layout rather than a published format contract. + +Recovery validates both slots independently. A torn slot is unavailable. With +two valid slots, identities and layouts must agree; equal sequences with +different payloads are an error. Select the highest sequence. A checksummed +but unsupported version, unknown mode or impossible boundary is an error, not +permission to choose an older record. No valid record means recovery stops +without modifying the input. Sequence or epoch exhaustion also stops writes. + +Two modes are required: + +| Mode | Recovery behavior | Writer admission | +|---|---|---| +| ACTIVE | Replay this generation from its physical floor | Allowed only after both current ACTIVE records are durable | +| EMPTY | Trust the fully flushed database and ignore all physical frame bytes | Closed until truncation and ACTIVE publication finish | + +The EMPTY mode prevents stale or invalid bytes from a previous generation from +being mistaken for a new log while reset is interrupted. Before a writer is +admitted, both slots must describe ACTIVE for that generation. Before obsolete +bytes are physically removed, both must describe EMPTY for the new generation. + +A version bump with the old `CWAL` magic is insufficient. The current +[file recovery path](../../src/CSharpDB.Storage/Wal/WriteAheadLog.cs) and +[memory recovery path](../../src/CSharpDB.Storage/Wal/MemoryWriteAheadLog.cs) +do not reject an unfamiliar version before replay. A probe of the unchanged +file-backed reader accepted version 77 with `CWAL`, but rejected `CWA2` before +changing the input. This verifies that reader's magic guard, not compatibility +with every historical release or a complete new-format codec. + +The first prototype creates fresh scratch databases only. No automatic upgrade, +downgrade, mixed-format live file, or modification of an already-open database +is allowed. Existing version-one databases continue using their existing path. +An eventual migration needs its own interruption and old-binary tests. + +## Retiring a copied prefix + +Use the existing checkpoint copy checks and writer/admission barriers. Do not +relax reader isolation in the first prototype. Let `F` be the captured published +boundary, `T` the current physical published tail, and `E` the generation. + +1. Complete and flush the database copy through `F`. Later committed frames + between `F` and `T` remain in the WAL. +2. Under finalization barriers, verify the captured generation and boundary + still apply. Preserve the existing pending-commit and reader checks. +3. Write slot A as ACTIVE for `E` with floor `F` and the next sequence. Flush it. +4. Write slot B with the same epoch, salts, floor and mode, and a higher sequence. + Flush it separately. +5. Retire the copied prefix from the live index and complete finalization. + Keep retained frame offsets and the published physical tail unchanged. + +Normal retirement does not move, truncate or overwrite any frame bytes. An +interruption before both records persist leaves either the previous boundary +or the new one. From valid input, either can replay the acknowledged state. +If an old boundary encounters a corrupt active prefix, recovery must fail +explicitly rather than return a silently shortened view. After successful +retirement, losing either control slot still leaves a boundary beyond the +obsolete invalid prefix covered by the new regression cases. + +This changes corruption handling for the proposed format. It does not grant +permission to change version-one recovery semantics. Torn/uncommitted tails, +commit markers and partial frame writes need separate new-format parser tests; +the abstract model below treats each frame as a complete committed record. + +## Reclaiming physical space + +Retirement alone does not reclaim bytes. At the budget, stop new writer +admission and reach a quiescent state with no active transaction, pending commit +or reader snapshot. If readers cannot drain within the configured wait, return +Busy before appending; never invalidate their views. Do not wait for reader +release while holding the coordinator lock required by that release. + +The first prototype uses a full drain instead of copying a suffix in place or +depending on file replacement and directory durability: + +| Step | Required durable state before proceeding | +|---|---| +| Copy every remaining committed page | Database flush completes through `T` | +| Publish EMPTY for generation `E + 1` | Slot A is written and flushed, then slot B is written and flushed | +| Truncate to `H` | Both EMPTY records are durable; truncate and flush the WAL | +| Publish ACTIVE for `E + 1`, floor `H` | Slot A is written and flushed, then slot B is written and flushed | +| Resume writers | Both ACTIVE records are durable; initialize empty live index and physical append position | + +The new generation gets new salts. The file identity stays fixed. Each slot +write advances the sequence, including changes of mode. A recovered EMPTY +state must finish truncation and mirrored ACTIVE publication before accepting +writes. A recovered ACTIVE state with a stale or missing peer record must repair +that peer before destructive maintenance or writer admission. + +Recovery must establish durability even when the readable slots already match. +A process-only crash can leave unflushed control bytes in the OS cache. Flush +the WAL before trusting a matching pair for truncation or admission. When a peer +needs repair, flushing that repair also flushes the selected record. If any +write, flush or truncation fails, keep admission closed and require reopen. + +The second ACTIVE flush is necessary. If a writer were admitted after only one +ACTIVE record persisted, failure of that record could make recovery select +EMPTY and ignore the newly acknowledged commit. Likewise, truncating before +EMPTY is mirrored can leave only an old boundary referring to removed bytes. + +This reset adds four control-record flushes and one truncation flush after the +database flush. Ordinary retirement adds two control-record flushes. Their +cost must be measured; reducing data movement does not establish a latency win. +The full drain may create a rarer, larger pause, which still counts in P99 and +maximum-latency qualification. + +## Physical size budget and backpressure + +Start the isolated integration experiment with a fixed 64 MiB WAL budget, +including `H`. This number is an experimental parameter, not a new default. +Keep separate values for physical append position, allocated file extent, +retired prefix bytes, live frames and outstanding append reservations. + +Reserve space before issuing any append. Check the physical append position +plus all outstanding reservations and the entire new request, using checked +arithmetic. Preallocation must stay within the budget. Retirement reduces live +work but never reduces the physical extent used by this check. + +At the limit, a quiescent full drain can make room. Inside an active transaction, +do not start a nested checkpoint or silently exceed the budget: reject further +append reservation and use the existing rollback/failure path. An individual +request larger than the available post-reset capacity fails predictably in the +prototype. Whether a shipping feature should offer a larger configurable cap, +another storage strategy or that rejection behavior remains a product decision. + +Begin with one writer and durable group commit disabled. A later concurrent +integration must reserve for every pending batch and release reservations once +on publish, cancellation or rollback. The abstract model's single-reservation +coverage does not qualify group commit. + +The existing [pager growth check](../../src/CSharpDB.Storage/Paging/Pager.cs) +uses live frame count and applies only while readers are active. It cannot +serve as this physical bound. `WalPreallocationChunkBytes` reserves capacity; +it is not a reclamation policy. + +## Integration boundaries + +| Component | Required change or qualification | +|---|---| +| [WriteAheadLog](../../src/CSharpDB.Storage/Wal/WriteAheadLog.cs) | Version-aware layout, control publication, new-format recovery, append reservations and restartable reset | +| [WalIndex](../../src/CSharpDB.Storage/Wal/WalIndex.cs) | Preserve an explicit physical published tail when retiring live frames; do not derive it from compacted frame count | +| [Pager](../../src/CSharpDB.Storage/Paging/Pager.cs) and [checkpoint coordinator](../../src/CSharpDB.Storage/Checkpointing/CheckpointCoordinator.cs) | Preserve copy visibility checks and lock order; add bounded admission and quiescent reclamation without deadlocking snapshot release | +| [MemoryWriteAheadLog](../../src/CSharpDB.Storage/Wal/MemoryWriteAheadLog.cs) | Explicitly support or reject imported new-format bytes; do not assume the version-one header length | +| [WalInspector](../../src/CSharpDB.Storage.Diagnostics/WalInspector.cs) and [diagnostic replay](../../src/CSharpDB.Storage.Diagnostics/Internal/InspectorEngine.cs) | Recognize layout and boundary or report unsupported format; never silently omit active WAL contents | +| [Retained snapshots](../../src/CSharpDB.Engine/RetainedDatabaseSnapshot.cs) and [snapshot file validation](../../src/CSharpDB.Engine/RetainedDatabaseSnapshotFile.cs) | Validate the selected boundary and physical/replay limits when copying the database/WAL pair | +| [Backup and restore](../../src/CSharpDB.Engine/DatabaseBackupCoordinator.cs) | Preserve the database/control-generation pairing and qualify import, export and recovery | + +Keep layout information per WAL instance. Changing the global +`PageConstants.WalHeaderSize` would break the existing format and its consumers. +The first engine prototype must have one exclusive owner and operate only on +fresh test files. Existing snapshots, old binaries and mixed-version tools are +separate compatibility gates before enabling the format for users. + +## Model evidence and limits + +The executable [boundary model](wal_boundary_model.py) passed **1,740** bounded +interruption/slot-failure cases: **1,445** recovered the expected state and +**295** rejected unusable or corrupt state as specified. It includes partial +database persistence before flush, absent/complete/torn control writes, reset +truncation cuts and one additional unreadable control slot. Four deliberately +incorrect operation orders produced the expected failure witnesses. + +A separate eight-step admission exploration reached **641 states** and checked +**1,791 transitions**, including reservations, publication, rollback, logical +retirement, pin/release and reset. It refused **358 reservations**. Two negative +accounting controls show why ignoring retired bytes or earlier reservations can +exceed a physical cap. Counts refer to the bounded model, not production tests. + +Run with a new output path: + +```text +python docs/design/wal_boundary_model.py --output +``` + +The model assumes ordered durable flushes and detected slot tears. It excludes +checksum collisions, actual byte encoding, torn frame/commit writes, OS I/O +behavior, concurrent scheduling, migration and power loss. Invalid active +frames fail closed. It does not prove that the complete CSharpDB implementation +is correct or fast. The earlier two recovery regressions still pass as part of +24 focused checkpoint batch tests against the unchanged production binary. + +## Implementation gates + +The control codec and isolated page-store integration pass their bounded checks, +including real process terminations. An opt-in committed-tail extension now +recovers the tested torn append cases, but its mirrored per-commit publication +failed the isolated cost screen described below. Keep this design experimental; +do not carry that protocol into the production Pager as a performance change. +The subsequent format-preserving 256-frame compaction experiment also failed +its paired performance gates, as recorded below. Park buffer-size tuning and +select the next target from end-to-end phase evidence. Any later format proposal +needs a cheaper commit-boundary protocol plus the existing engine reader, +group-commit, realistic-growth and format-consumer qualification gates. + +For timing, retain the existing paired latency/throughput/allocation gates and +include enough data to cross the physical budget repeatedly. Report P99 and +maximum transaction latency, throughput with final drain, total bytes moved and +flushed, maximum allocated WAL extent, Busy responses, and reopen/replay cost. +Do not omit reclamation pauses or compare against a baseline with a different +durability guarantee. Adoption requires a repeatable measured benefit after +these correctness and compatibility gates. + +## Isolated control codec and file experiment + +The [prototype test project](../../tests/CSharpDB.WalPrototype.Tests/CSharpDB.WalPrototype.Tests.csproj) +has no product references or solution entry. It cannot change how a production +database opens or writes. Its [codec](../../tests/CSharpDB.WalPrototype.Tests/ControlRecord.cs) +uses the following offsets within each 4,096-byte slot. All integers are unsigned +little-endian values; the floor must also fit a signed 64-bit file offset. + +| Offset | Bytes | Field | +|---|---|---| +| 0 | 4 | ASCII `CWA2` | +| 4 | 4 | Version 2 | +| 8 | 4 | Feature flags: zero for floor-only; bit 0 enables the experimental committed tail | +| 12 | 4 | Slot size, 4,096 | +| 16 | 4 | Page size, 4,096 | +| 20 | 4 | Frame size, 4,120 | +| 24 | 8 | Data start, 8,192 | +| 32 | 16 | Opaque identity as two 64-bit words; not Guid byte order | +| 48 | 8 | Sequence, starting at 1 | +| 56 | 8 | Epoch, starting at 1 | +| 64 | 8 | Two nonzero 32-bit generation salts | +| 72 | 4 | Mode: ACTIVE = 1, EMPTY = 2 | +| 76 | 4 | Reserved zero | +| 80 | 8 | Absolute replay floor | +| 88 | 4 | Checkpoint page count | +| 92 | 4 | Reserved zeros | +| 96 | 8 | Committed tail when feature bit 0 is set; otherwise zero | +| 104 | 3,960 | Reserved zeros | +| 4,064 | 32 | SHA-256 of bytes 0 through 4,063 | + +Identity generation uses 128 cryptographically random bits and rejects the +all-zero value. Salt generation uses two random nonzero words and rejects the +previous pair. The checksum protects integrity; it does not authenticate a file. +Unknown fields or nonzero reserved bytes with a valid checksum are errors. +The decoder permits a terminal counter value for inspection, but the protocol +refuses admission or maintenance that exhausts the counters and never wraps. + +Floors must be at least `H`, frame-aligned relative to `H`, and no greater than +the physical file length. EMPTY requires floor `H`. An unaligned physical tail +does not itself invalidate the control record; a future frame parser must +determine which trailing bytes constitute complete committed data. The codec +does not mistake allocated file extent for a proven commit boundary. + +The [protocol](../../tests/CSharpDB.WalPrototype.Tests/ControlProtocol.cs) writes +and flushes each slot separately, repairs a stale or missing peer, completes +interrupted EMPTY resets, and keeps admission closed after any failure. Quiescence +and the copied/committed boundary are inputs to this experiment; engine locks, +reader tracking, physical-budget reservations and commit parsing are not yet +implemented here. + +The final Release run passed **51 tests**, including these bounded checks: + +- An independently packed little-endian golden vector, 32,768 single-bit + corruptions, 8,194 prefix/suffix tear images, and all 256 combinations of eight + 512-byte sectors. A tear must decode exactly the old/new record or be rejected. +- **3,831 file-backed interruption cases** across retirement, reset and restart + repair: **2,747** recovered the exact expected pages and **1,084** failed closed + as specified. Cases include partial base-page persistence, torn slot writes, + interrupted flushes/truncation, and loss of either additional control slot. +- **18 post-admission append cases** retained the new acknowledged fixture page + after loss of either slot. Exceptions before/after each of 12 reset I/O + operations stopped further work; four recovery-flush failures kept admission + closed. Four incorrect-order controls exposed the expected hidden commit or + unusable recovery boundary. + +The harness exercises real file writes and `Flush(true)`, then writes each +modeled interruption image to a separate file, closes it and reopens it through +the codec. Recovery checks leave every input byte unchanged. Its page fixture +uses whole, individually committed records and known checkpoint page contents; +it does not implement the production frame checksum, commit markers or corrupt +page repair. These are deterministic fault images, **not actual power-loss or +process-kill tests**, and they do not qualify the engine's durability or speed. + +Build and run this project directly, choosing a fresh evidence directory: + +```powershell +dotnet build tests/CSharpDB.WalPrototype.Tests/CSharpDB.WalPrototype.Tests.csproj -c Release +$env:CSHARPDB_WAL_PROTOTYPE_EVIDENCE = '' +dotnet tests/CSharpDB.WalPrototype.Tests/bin/Release/net10.0/CSharpDB.WalPrototype.Tests.dll +``` + +The harness preserves each case in a unique subdirectory. With no environment +override, it uses `CSharpDB.WalPrototype` under the system temporary directory. +The recorded runs are under `.tmp/wal-control-codec-20261005`. The initial 50-test +pass and its source snapshot remain preserved; the final run adds the explicit +recovery durability barrier. Production source and all eleven qualified product +DLLs remain unchanged. No new performance claim or full production-suite result +is implied by this experiment. + +## Isolated page store and real process interruptions + +The [experimental page store](../../tests/CSharpDB.WalPrototype.Tests/ExperimentalWalStore.cs) +now connects the control protocol to file-backed transactional append, replay, +checkpoint retirement and bounded reset. It operates only on fresh raw page +files, with one owner and writer. This is a storage-level integration, not an +implementation in the production Pager or SQL engine. + +Its [frame codec](../../tests/CSharpDB.WalPrototype.Tests/WalFrameCodec.cs) uses the +existing 24-byte header, 4,096-byte payload, default additive checksum and +nonzero final-frame page-count commit marker. The test and crash-worker projects +link the unchanged checksum source; they do not build or reference product DLLs. +The marker preserves the checksum of the encoded page already written, avoiding +a second checksum over a caller buffer that could have changed. + +The store keeps physical append offsets after prefix retirement, reserves the +whole batch before writing, and counts retired bytes toward the physical cap. +It has no preallocation or group commit. A batch larger than post-reset capacity +is rejected before I/O. At the cap, a quiescent full drain/reset makes space; +reader pins or a pending checkpoint return Busy before another append. The +prototype supports page-count growth up to 65,536 pages, but not shrinking. +That bound is an experiment limit, not a product setting. + +Reader snapshots retain their own page-offset map and generation. Their base +fallback pages remain stable because any active reader conservatively blocks +base copying and reset. The test store uses a single gate and returns Busy +without waiting while holding it; reader release can therefore make progress. +These checks do not qualify the more permissive production checkpoint coordinator. +Cancellation before the commit marker truncates and flushes back to the prior +published tail before permitting another batch. I/O failure closes the store +until reopen and releases its reservation. + +Recovery validates the complete active region before repairing controls or +changing either file. Its current tail rules deliberately favor detection: + +| Recovered suffix | Result | +|---|---| +| Complete checksummed transaction with a commit marker | Publish the whole batch | +| Complete checksummed frames with no final commit marker | Discard the entire pending batch before the next append | +| Partial header/payload, torn marker, invalid checksum, generation or page range | Fail without modifying either file | +| Invalid bytes below the durable replay floor | Skip the retired prefix and recover later committed frames | + +The last-frame shape alone cannot distinguish every interrupted append from +damage to acknowledged data. Five tested mid-write process crashes therefore +leave an explicitly rejected WAL instead of an automatically reopened database. +This is an **availability limitation**, not a passing recovery result, and needs +resolution before adoption. This experiment does not change version-one recovery. + +The final Release build had no warnings or errors, and **121 tests passed**: +30 codec, 21 control-protocol, 33 page-store and 37 process-crash cases. The 51 +earlier control tests are included, along with their 3,831 modeled fault images. +The new storage coverage includes whole-batch replay, duplicate pages, growth, +both corrupt retired-prefix cases, partial/clean uncommitted tails, active +corruption, cancellation rollback, buffer ownership, oversize rejection, and +concurrent writer/pinned-reader views. + +The [crash tests](../../tests/CSharpDB.WalPrototype.Tests/WalProcessCrashTests.cs) +performed **43 actual child-process terminations** across 37 cases: 31 first +interruptions and six two-stage interrupted-reset/recovery cases. Each worker +recorded the reached point and its PID, then killed only itself without disposing +the store. Cases cover partial frames/markers, commit return, checkpoint copying, +both control slots, truncation and writer admission. Of the 31 first interruptions, +26 reopened with the specified committed state and five rejected ambiguous +tails without changing input bytes. All six interrupted recoveries completed on +the following open; 12 additional slot-loss checks preserved a commit appended +after that recovery. These are process-crash tests with the OS still running, +not power-loss tests. + +A bounded physical-growth test made **24 two-frame commits**, crossed a four-frame +budget through **11 resets**, and reopened after every commit. Maximum WAL extent +was **24,672 bytes**, exactly its configured test cap. This proves the tested +accounting/reset path, not long-running engine capacity or latency. + +Evidence and pre-recovery file copies are preserved under +`.tmp/wal-storage-prototype-20261005`. The first focused 68-test pass is retained; +the final run adds cancellation/buffer-ownership guards and reruns the full +isolated suite. Production source, all eleven qualified product DLLs, prior +regression edits and unrelated files remain unchanged. No timing workload or +production suite was rerun. Compatibility/migration, actual engine reader modes, +backup/inspection consumers, group commit and OS/power-loss durability remain +separate gates before any performance claim. + +## Committed-tail recovery option and rejected commit-cost screen + +The October 6 extension gives fresh experimental files an explicit durable +committed tail. It is opt-in through `committedTail: true` at creation; opening a +floor-only file does not upgrade it. Both slots must agree on feature presence. +The tail at byte 96 is protected by the slot's SHA-256, aligned relative to `H`, +at least the replay floor and no greater than the physical file extent. EMPTY +requires both floor and tail to be `H`. A checksum-valid impossible tail is an +error even when the peer record could otherwise be used. + +The [publication protocol](../../tests/CSharpDB.WalPrototype.Tests/ControlProtocol.cs) +orders a commit as follows: + +1. Write all frames and their final commit marker, then flush the WAL data. +2. Write and flush ACTIVE slot A with the new committed tail. +3. Write and flush ACTIVE slot B with that same tail and a higher sequence. +4. Publish the in-memory page map and return success. + +A recovered higher-tail record can safely include a complete batch whose caller +never received success. A surviving older-tail record can omit only an +unacknowledged batch, because acknowledged commits had both current copies +flushed. Recovery validates every frame through the selected tail and requires +it to end at a complete commit boundary. It then repairs/flushes the controls +before trimming later bytes. Those later bytes are proven unacknowledged by +this protocol, whether they contain a partial frame, torn marker or complete +but unpublished batch. Damage within the declared committed range still fails +without changing either input file. + +Cancellation may truncate back only before commit publication starts. Once +publication has started, any failure requires reopen; truncating then could +remove bytes already named by a surviving higher-tail record. Reset advances +the generation and returns the tail to `H` only after flushing the complete base +copy and mirroring EMPTY. Ordinary retirement advances the floor and preserves +the latest committed tail, including later commits after the captured copy. + +The final Release suite passed **179 tests**, retaining the earlier 121 cases. +The new option passed **41 process-crash cases with 47 actual terminations**, +including all five torn-write points rejected by the floor-only prototype and +six twice-interrupted reset/recovery cases. The full suite terminated 90 child +processes across the two formats. Twelve post-recovery slot-loss checks retained +a newly acknowledged commit in the new format. Additional guards cover loss of +either control followed by a torn append, damaged/truncated acknowledged data, +a reverted commit marker, a boundary inside a transaction, cancellation during +publication, page growth, reader backpressure and generation reset. + +A separate **369-case** file-backed control-publication matrix recovered the +prior committed state in 148 cases and the newly published state in 157 cases. +It rejected 64 cases where a torn slot plus the additional injected slot loss +left neither control readable. Those refusals do not discard acknowledged data. +The matrix assumes the ordered successful data flush before tail publication; +actual power loss and storage-device flush guarantees remain unqualified. + +The correctness fix is too expensive for the current performance objective. +It requires **three WAL flushes per commit instead of one**, plus **8,192 control +bytes written per commit**. A fixed ABBA screen ran the same fresh Release binary +with only the creation flag changed: 40 warmup commits in a separate file, then +500 one-page commits per slot, a full drain and exact durable reopen. All four +reopens passed. The desktop remained in normal use, with no idle gates or reruns. + +| Pair / execution order | Floor-only P99 | Committed-tail P99 | Floor-only commits/s incl. drain | Committed-tail commits/s incl. drain | +|---|---:|---:|---:|---:| +| Baseline then candidate | 3.8555 ms | 13.2950 ms | 451.88 | 141.74 | +| Candidate then baseline | 4.0372 ms | 10.5400 ms | 446.05 | 150.68 | + +P99 was **2.61–3.45 times baseline**, throughput fell **66.2–68.6%**, and measured +allocation rose by about **9.2 KB per commit** in both pairs. These are isolated +page-store commit costs, not SQL/engine numbers or an end-to-end reclamation +comparison. The timed loop did not cross its 64 MiB cap; final-drain time is +included in the throughput shown above. No performance promotion follows from +the correctness results. + +The extension remains disabled by default in the test prototype, and production +code and all eleven qualified product DLLs remain unchanged. Evidence, the fixed +screen protocol, all four captures, test output and source snapshots are retained +under `.tmp/wal-commit-boundary-20261006`. The direct per-commit mirrored-tail +approach is parked as a correctness reference. The following experiment evaluated +format-preserving compaction batching. Revisit a new WAL format only with a +commit-boundary design that can meet the latency and allocation gates. + +## Format-preserving compaction batching screen + +The October 6 follow-up tested 256-frame sequential compaction moves instead of +16-frame moves in an isolated Storage build. A temporary pooled buffer served +larger suffixes, with an explicit frame-aligned logical size of at most 1,054,720 +bytes and return in `finally`; smaller suffixes retained the existing buffer. +No database-copy, WAL-format, durable-flush or index-publication ordering changed. + +The candidate passed 133 focused existing WAL/checkpoint/fault cases. A separate +current checkpoint-class build passed 29 cases on each arm, adding boundary +sizes 255, 256, 512, 513 and 4097 to the existing overlap/recovery coverage. +Both builds had zero warnings/errors. These are bounded correctness checks; +full candidate qualification was conditional on passing the performance screen. + +The fixed normal-desktop ABBA screen used identical uninstrumented harness code +and dependencies except Storage, with 300 measured commits of 10,000 rows per +slot. All four measured and two smoke processes passed exact durable reopen. +Whole-operation P99 improved only **3.96% and 6.45%**, below the required 10% in +both pairs. Allocations rose **1.381% and 1.387%**, exceeding the 1% gate; +throughput including drain fell **0.018% and 2.402%**. P95 passed its 5% guard. +There were no retries or smaller-workload guards after rejection. + +The candidate was not adopted, and production sources/binaries remain unchanged. +Evidence is preserved under `.tmp/wal-compaction-batching-20261006`. Alongside the +earlier rejected 128-frame and read-prefetch variants, this result parks further +buffer-size tuning. Choose a different bottleneck from end-to-end phase evidence +before another optimization; these timings do not establish the cause of the +remaining stall or the previously intermittent extreme tail. diff --git a/docs/design/wal_boundary_model.py b/docs/design/wal_boundary_model.py new file mode 100644 index 00000000..54921c22 --- /dev/null +++ b/docs/design/wal_boundary_model.py @@ -0,0 +1,267 @@ +"""Bounded abstract model for the proposed WAL control-record protocol. + +This is not a WAL implementation or a filesystem/power-loss test. Frames are +atomic committed records; an invalid control slot represents a detected tear. +Successful flushes persist in order. No checksum collisions are modeled. +""" +from copy import deepcopy +from dataclasses import dataclass, asdict +import argparse +import json + + +@dataclass(frozen=True) +class Control: + sequence: int + epoch: int + floor: int + mode: str = "active" + identity: str = "database-a" + + +@dataclass +class Disk: + slots: list + base: dict + frames: list + bad: set + + +class RecoveryError(Exception): + pass + + +def recover(disk): + valid = [x for x in disk.slots if x is not None] + if not valid: + raise RecoveryError("no valid control record") + if len({x.identity for x in valid}) != 1: + raise RecoveryError("control identity conflict") + if len(valid) == 2 and valid[0].sequence == valid[1].sequence and valid[0] != valid[1]: + raise RecoveryError("conflicting records at the same sequence") + control = max(valid, key=lambda x: x.sequence) + if control.mode not in ("active", "empty"): + raise RecoveryError("unknown control mode") + result = disk.base.copy() + if control.mode == "empty": + return result + if not 0 <= control.floor <= len(disk.frames): + raise RecoveryError("replay boundary outside physical extent") + for position, (epoch, key, value) in enumerate(disk.frames[control.floor:], control.floor): + if position in disk.bad: + raise RecoveryError("invalid active frame") + if epoch != control.epoch: + raise RecoveryError("active frame generation mismatch") + result[key] = value + return result + + +def expected(frames, bad): + result = {} + for position, (_, key, value) in enumerate(frames): + if position not in bad: + result[key] = value + return result + + +def publish(cuts, disk, slot, control, name): + # Each write can be absent, complete, or detectably torn at interruption. + for persistence in ("absent", "complete", "torn"): + cut = deepcopy(disk) + if persistence != "absent": + cut.slots[slot] = control if persistence == "complete" else None + cuts.append((f"{name}-write-{persistence}", cut, False)) + disk.slots[slot] = control + cuts.append((f"{name}-flushed", deepcopy(disk), False)) + + +def copy_flush(cuts, disk, target, name): + changed = [k for k, v in target.items() if disk.base.get(k) != v] + for mask in range(1 << len(changed)): + cut = deepcopy(disk) + for i, key in enumerate(changed): + if mask & (1 << i): + cut.base[key] = target[key] + cuts.append((f"{name}-before-flush-{mask}", cut, False)) + disk.base.update(target) + cuts.append((f"{name}-flushed", deepcopy(disk), False)) + + +def corrupt_slot(disk, slot): + disk = deepcopy(disk) + if slot is not None: + disk.slots[slot] = None + return disk + + +def verify_cuts(cuts, wanted, prefix_corrupt, output, operation): + count = 0 + errors = 0 + for name, disk, completed in cuts: + for failed_slot in (None, 0, 1): + trial = corrupt_slot(disk, failed_slot) + try: + result = recover(trial) + except RecoveryError: + errors += 1 + # A valid input with a surviving control must recover; so must + # a completed floor publication even with an invalid old prefix. + must_recover = completed or (not prefix_corrupt and any(trial.slots)) + assert not must_recover, (operation, name, failed_slot, asdict(trial)) + else: + assert result == wanted, (operation, name, failed_slot, result, wanted) + count += 1 + output.append(dict(operation=operation,crashAndSlotCases=count,failClosedCases=errors)) + + +def boundary_cases(): + frames = [(1, 0, 1), (1, 1, 1), (1, 0, 2), (1, 2, 1), (1, 1, 2), (1, 3, 1)] + results = [] + for floor in range(1, len(frames)): + # None, an indexed last prefix frame, and a superseded prefix frame. + invalid_sets = [set(), {floor - 1}] + if floor >= 3: + invalid_sets.append({0}) + for bad in invalid_sets: + disk = Disk([Control(0, 1, 0)] * 2, {}, frames.copy(), bad) + wanted = expected(frames, bad) + cuts = [("initial", deepcopy(disk), False)] + copied = expected(frames[:floor], bad) + copy_flush(cuts, disk, copied, "copy-prefix") + publish(cuts, disk, 0, Control(1, 1, floor), "floor-a") + publish(cuts, disk, 1, Control(2, 1, floor), "floor-b") + cuts.append(("floor-mirrored-before-return", deepcopy(disk), True)) + verify_cuts(cuts, wanted, bool(bad), results, f"floor-{floor}-bad-{sorted(bad)}") + return results + + +def reset_cases(): + frames = [(1, 0, 1), (1, 1, 1), (1, 0, 2), (1, 2, 1), (1, 1, 2), (1, 3, 1)] + results = [] + for floor in range(1, len(frames)): + for bad in (set(), {0}): + copied = expected(frames[:floor], bad) + disk = Disk([Control(1, 1, floor), Control(2, 1, floor)], copied, frames.copy(), bad) + wanted = expected(frames, bad) + cuts = [("initial", deepcopy(disk), False)] + copy_flush(cuts, disk, wanted, "copy-active") + publish(cuts, disk, 0, Control(3, 2, 0, "empty"), "empty-a") + publish(cuts, disk, 1, Control(4, 2, 0, "empty"), "empty-b") + # A truncated tail may persist to any modeled frame boundary. + for length in range(len(frames) + 1): + cut = deepcopy(disk);cut.frames = cut.frames[:length] + cuts.append((f"truncate-before-flush-{length}", cut, False)) + disk.frames = [];disk.bad = set() + cuts.append(("truncate-flushed", deepcopy(disk), False)) + publish(cuts, disk, 0, Control(5, 2, 0), "active-a") + publish(cuts, disk, 1, Control(6, 2, 0), "active-b") + cuts.append(("active-mirrored", deepcopy(disk), True)) + # The invalid prefix is already below both initial floors. Recovery + # must therefore succeed at every cut with any surviving control. + verify_cuts(cuts, wanted, False, results, f"reset-{floor}-bad-{sorted(bad)}") + disk.frames.append((2, 1, 99));wanted = wanted | {1: 99} + verify_cuts([("new-commit-acknowledged", disk, True)], wanted, False, results, + f"reset-{floor}-new-commit-bad-{sorted(bad)}") + return results + + +def negative_controls(): + witnesses = [] + # New EMPTY record before the base flush silently hides committed pages. + disk = Disk([Control(1, 2, 0, "empty"), Control(0, 1, 0)], {}, [(1, 0, 7)], set()) + assert recover(disk) != {0: 7} + witnesses.append("Publishing EMPTY before the base flush hides acknowledged data.") + # Admitting writes after one ACTIVE copy can fall back to EMPTY. + disk = Disk([None, Control(4, 2, 0, "empty")], {0: 7}, [(2, 0, 8)], set()) + assert recover(disk) != {0: 8} + witnesses.append("Admitting writes before ACTIVE is mirrored can hide a new commit after one slot fails.") + # Reclaiming before EMPTY is mirrored can leave only an old replay boundary. + disk = Disk([None, Control(2, 1, 1)], {0: 7}, [], set()) + try: + recover(disk) + except RecoveryError: + witnesses.append("Truncating before EMPTY is mirrored can leave an unusable old boundary.") + else: + raise AssertionError("Unsafe early truncation was accepted.") + # Completing retirement after one floor copy loses the invalid-prefix guard. + disk = Disk([None, Control(0, 1, 0)], {0: 7}, [(1, 0, 6), (1, 1, 8)], {0}) + try: + recover(disk) + except RecoveryError: + witnesses.append("Completing retirement before both floor copies persist can expose an invalid prefix.") + else: + raise AssertionError("Single-copy retirement was accepted.") + return witnesses + + +def admission_cases(): + # Units are abstract frame extents. State is physical used extent, live + # frames, reserved append extent, reader pin, and allocated extent. + header = 2 + states = transitions = refused = 0 + for budget in (8, 16): + initial = (header, 0, 0, False, header) + frontier = {initial};seen = {initial} + for _ in range(8): + following = set() + for used, live, reserved, pins, allocated in frontier: + next_states = [] + if reserved == 0: + for requested in (1, 3, 5, budget + 1): + if used + requested <= budget: + # Preallocation rounds up but is clamped at the cap. + extent = min(budget, ((used + requested + 3) // 4) * 4) + next_states.append((used, live, requested, pins, max(allocated, extent))) + else: + refused += 1 + # A quiescent reset can make room only if the entire + # request fits; a pinned reader cannot be bypassed. + if not pins and header + requested <= budget: + reset_then_publish = (header + requested, requested, 0, False, header + requested) + next_states.append(reset_then_publish) + else: + next_states.append((used + reserved, live + reserved, 0, pins, allocated)) + next_states.append((used, live, 0, pins, allocated)) # rollback + next_states.append((used, live, reserved, not pins, allocated)) + if not pins and reserved == 0: + next_states.append(initial) # quiescent full drain and reset + if live > 1: + next_states.append((used, live - live // 2, 0, False, allocated)) + for state in next_states: + u, l, r, _, a = state + assert header <= u <= a <= budget and u + r <= a + assert 0 <= l <= u - header and r >= 0 + transitions += 1 + if state not in seen: + following.add(state);seen.add(state) + frontier = following + states += len(seen) + # Negative accounting controls: the live frame estimate hides retired bytes, + # and checking a new reservation alone hides an earlier pending reservation. + assert header + 1 + 1 <= 8 and 8 + 1 > 8 + assert 6 + 1 <= 8 and 6 + 2 + 1 > 8 + return dict(reachableStates=states,checkedTransitions=transitions,refusedReservations=refused, + depth=8,budgetsInFrameUnits=[8,16],negativeControls=2, + limits="One outstanding reservation in the transition model; a separate negative control covers double booking. Reader visibility and concurrency scheduling are not modeled.") + + +def main(): + parser = argparse.ArgumentParser();parser.add_argument("--output", required=True) + args = parser.parse_args() + boundary = boundary_cases();reset = reset_cases();negative = negative_controls() + result = dict(status="bounded-model-passed", boundaryScenarios=len(boundary), resetScenarios=len(reset), + crashAndSlotCases=sum(x['crashAndSlotCases'] for x in boundary + reset), + failClosedCases=sum(x['failClosedCases'] for x in boundary + reset), + admission=admission_cases(),negativeControls=negative,scenarios=boundary + reset, + assumptions=["Ordered successful flushes persist; nonflushed slot writes may be absent, complete, or detectably torn.", + "One additional unreadable slot per cut; two unreadable slots fail closed.", + "Frames represent whole committed records; concurrent transactions and torn frame writes are outside this model.", + "Invalid active frames fail closed, never return a truncated logical view.", + "Read pins and reservations are abstract admission inputs, not a concurrency implementation."], + limitations="Not a codec, filesystem, process-kill, power-loss, frame-parser, migration or performance qualification.") + with open(args.output, "x", encoding="utf-8") as f:json.dump(result,f,indent=2);f.write("\n") + print(json.dumps({k:v for k,v in result.items() if k not in ['scenarios','assumptions','limitations']})) + + +if __name__ == "__main__": + main() diff --git a/docs/releases/changelog-publication.json b/docs/releases/changelog-publication.json index b52d9d1b..941ce1ec 100644 --- a/docs/releases/changelog-publication.json +++ b/docs/releases/changelog-publication.json @@ -1,12 +1,16 @@ { "coverageFrom": "3.8.0", - "verifiedOn": "2026-09-28", + "verifiedOn": "2026-10-06", "source": "https://api.github.com/repos/MaxAkbar/CSharpDB/releases", "notesReview": { - "version": "4.6.5", - "sha256": "e458656bf825821ec4b29859d13f5283dc114d0a288e77dcf4b02623cf08b0fe" + "version": "4.6.6", + "sha256": "2927cbf938795999f8e10637e2184e87e9ed3eff6b311fd9acdb90e63b3c0225" }, "releases": [ + { + "version": "4.6.5", + "publishedDate": "2026-09-28" + }, { "version": "4.6.4", "publishedDate": "2026-09-12" diff --git a/docs/releases/downloads-publication.json b/docs/releases/downloads-publication.json index a6bb3b19..e8f4862a 100644 --- a/docs/releases/downloads-publication.json +++ b/docs/releases/downloads-publication.json @@ -1,42 +1,42 @@ { "assets": [ "ADMIN-SHA256SUMS.txt", - "csharpdb-admin-desktop-v4.6.4-win-x64.zip", - "csharpdb-daemon-v4.6.4-linux-x64.tar.gz", - "csharpdb-daemon-v4.6.4-osx-arm64.tar.gz", - "csharpdb-daemon-v4.6.4-win-x64.zip", - "csharpdb-migration-tool-v4.6.4-linux-x64.tar.gz", - "csharpdb-migration-tool-v4.6.4-osx-arm64.tar.gz", - "csharpdb-migration-tool-v4.6.4-win-x64.zip", + "csharpdb-admin-desktop-v4.6.5-win-x64.zip", + "csharpdb-daemon-v4.6.5-linux-x64.tar.gz", + "csharpdb-daemon-v4.6.5-osx-arm64.tar.gz", + "csharpdb-daemon-v4.6.5-win-x64.zip", + "csharpdb-migration-tool-v4.6.5-linux-x64.tar.gz", + "csharpdb-migration-tool-v4.6.5-osx-arm64.tar.gz", + "csharpdb-migration-tool-v4.6.5-win-x64.zip", "CSharpDB-PACKAGE-MANIFEST.json", - "CSharpDB.4.6.4.nupkg", - "CSharpDB.Client.4.6.4.nupkg", - "CSharpDB.CodeModules.4.6.4.nupkg", - "CSharpDB.Data.4.6.4.nupkg", - "CSharpDB.DevOps.4.6.4.nupkg", - "CSharpDB.Engine.4.6.4.nupkg", - "CSharpDB.EntityFrameworkCore.4.6.4.nupkg", - "CSharpDB.EntityFrameworkCore.Tools.4.6.4.nupkg", - "CSharpDB.Execution.4.6.4.nupkg", - "CSharpDB.Generators.4.6.4.nupkg", + "CSharpDB.4.6.5.nupkg", + "CSharpDB.Client.4.6.5.nupkg", + "CSharpDB.CodeModules.4.6.5.nupkg", + "CSharpDB.Data.4.6.5.nupkg", + "CSharpDB.DevOps.4.6.5.nupkg", + "CSharpDB.Engine.4.6.5.nupkg", + "CSharpDB.EntityFrameworkCore.4.6.5.nupkg", + "CSharpDB.EntityFrameworkCore.Tools.4.6.5.nupkg", + "CSharpDB.Execution.4.6.5.nupkg", + "CSharpDB.Generators.4.6.5.nupkg", "csharpdb.h", - "CSharpDB.ImportExport.4.6.4.nupkg", - "CSharpDB.Migration.4.6.4.nupkg", - "CSharpDB.Migration.DualRun.4.6.4.nupkg", + "CSharpDB.ImportExport.4.6.5.nupkg", + "CSharpDB.Migration.4.6.5.nupkg", + "CSharpDB.Migration.DualRun.4.6.5.nupkg", "CSharpDB.Native.dll", "CSharpDB.Native.dylib", "CSharpDB.Native.so", - "CSharpDB.Observability.4.6.4.nupkg", - "CSharpDB.Pipelines.4.6.4.nupkg", - "CSharpDB.Primitives.4.6.4.nupkg", - "CSharpDB.Sql.4.6.4.nupkg", - "CSharpDB.Storage.4.6.4.nupkg", - "CSharpDB.Storage.Diagnostics.4.6.4.nupkg", + "CSharpDB.Observability.4.6.5.nupkg", + "CSharpDB.Pipelines.4.6.5.nupkg", + "CSharpDB.Primitives.4.6.5.nupkg", + "CSharpDB.Sql.4.6.5.nupkg", + "CSharpDB.Storage.4.6.5.nupkg", + "CSharpDB.Storage.Diagnostics.4.6.5.nupkg", "MIGRATION-SHA256SUMS.txt", "SHA256SUMS.txt" ], - "publishedDate": "2026-09-12", - "version": "4.6.4", - "verifiedOn": "2026-09-28", + "publishedDate": "2026-09-28", + "version": "4.6.5", + "verifiedOn": "2026-10-06", "source": "https://api.github.com/repos/MaxAkbar/CSharpDB/releases/latest" } diff --git a/scripts/Publish-CSharpDbMigrationRelease.ps1 b/scripts/Publish-CSharpDbMigrationRelease.ps1 index 08bbba40..e9378839 100644 --- a/scripts/Publish-CSharpDbMigrationRelease.ps1 +++ b/scripts/Publish-CSharpDbMigrationRelease.ps1 @@ -518,7 +518,7 @@ function Assert-FrameworkDependentStage { (Join-Path $StageRoot 'VERSION.txt'), (Join-Path $StageRoot "adapters/sqlserver/csharpdb-migration-sqlserver-worker$executableSuffix"), (Join-Path $StageRoot 'adapters/sqlserver/THIRD-PARTY-NOTICES.md'), - (Join-Path $StageRoot 'adapters/sqlserver/licenses/Microsoft.Data.SqlClient.SNI.runtime-6.0.3-LICENSE.txt'), + (Join-Path $StageRoot 'adapters/sqlserver/licenses/Microsoft.Data.SqlClient.SNI.runtime-7.1.0-LICENSE.txt'), (Join-Path $StageRoot "adapters/mysql/csharpdb-migration-mysql-worker$executableSuffix"), (Join-Path $StageRoot 'adapters/mysql/THIRD-PARTY-NOTICES.md'), (Join-Path $StageRoot 'install/windows/install-csharpdb-migration-tool.ps1'), diff --git a/scripts/Publish-CSharpDbSqlServerMigrationBundle.ps1 b/scripts/Publish-CSharpDbSqlServerMigrationBundle.ps1 index bb292b43..7fb41457 100644 --- a/scripts/Publish-CSharpDbSqlServerMigrationBundle.ps1 +++ b/scripts/Publish-CSharpDbSqlServerMigrationBundle.ps1 @@ -139,17 +139,17 @@ function Assert-ReviewedWorkerPackageClosure { ) $reviewedPackages = @( - 'Microsoft.Bcl.Cryptography/9.0.13' - 'Microsoft.Data.SqlClient.Extensions.Abstractions/7.0.3' - 'Microsoft.Data.SqlClient.Internal.Logging/7.0.3' - 'Microsoft.Data.SqlClient.SNI.runtime/6.0.3' - 'Microsoft.Data.SqlClient/7.0.3' - 'Microsoft.Extensions.Caching.Abstractions/9.0.13' - 'Microsoft.Extensions.Caching.Memory/9.0.13' + 'Microsoft.Bcl.Cryptography/9.0.18' + 'Microsoft.Data.SqlClient.Extensions.Abstractions/7.1.1' + 'Microsoft.Data.SqlClient.Internal.Logging/7.1.1' + 'Microsoft.Data.SqlClient.SNI.runtime/7.1.0' + 'Microsoft.Data.SqlClient/7.1.1' + 'Microsoft.Extensions.Caching.Abstractions/9.0.18' + 'Microsoft.Extensions.Caching.Memory/9.0.18' 'Microsoft.Extensions.DependencyInjection.Abstractions/10.0.0' 'Microsoft.Extensions.Logging.Abstractions/10.0.0' - 'Microsoft.Extensions.Options/9.0.13' - 'Microsoft.Extensions.Primitives/9.0.13' + 'Microsoft.Extensions.Options/9.0.18' + 'Microsoft.Extensions.Primitives/9.0.18' 'Microsoft.IdentityModel.Abstractions/8.16.0' 'Microsoft.IdentityModel.JsonWebTokens/8.16.0' 'Microsoft.IdentityModel.Logging/8.16.0' @@ -157,12 +157,13 @@ function Assert-ReviewedWorkerPackageClosure { 'Microsoft.IdentityModel.Protocols/8.16.0' 'Microsoft.IdentityModel.Tokens/8.16.0' 'Microsoft.SqlServer.Server/1.0.0' - 'Microsoft.SqlServer.TransactSql.ScriptDom/180.107.0' - 'System.Configuration.ConfigurationManager/9.0.13' - 'System.Diagnostics.EventLog/9.0.13' + 'Microsoft.SqlServer.TransactSql.ScriptDom/180.117.0' + 'System.Configuration.ConfigurationManager/9.0.18' + 'System.Diagnostics.EventLog/9.0.18' 'System.IdentityModel.Tokens.Jwt/8.16.0' - 'System.Security.Cryptography.Pkcs/9.0.13' - 'System.Security.Cryptography.ProtectedData/9.0.13' + 'System.Security.Cryptography.Pkcs/9.0.18' + 'System.Security.Cryptography.ProtectedData/9.0.18' + 'System.Threading.RateLimiting/9.0.18' ) | Sort-Object $expectedPackages = if ($IncludesSniRuntime) { $reviewedPackages @@ -171,7 +172,7 @@ function Assert-ReviewedWorkerPackageClosure { @( $reviewedPackages | Where-Object { - $_ -ne 'Microsoft.Data.SqlClient.SNI.runtime/6.0.3' + $_ -ne 'Microsoft.Data.SqlClient.SNI.runtime/7.1.0' } ) } @@ -273,7 +274,7 @@ $requiredFiles = @( (Join-Path $workerOutput 'Microsoft.Data.SqlClient.dll'), (Join-Path $workerOutput 'Microsoft.SqlServer.TransactSql.ScriptDom.dll'), (Join-Path $workerOutput 'THIRD-PARTY-NOTICES.md'), - (Join-Path $workerOutput 'licenses/Microsoft.Data.SqlClient.SNI.runtime-6.0.3-LICENSE.txt'), + (Join-Path $workerOutput 'licenses/Microsoft.Data.SqlClient.SNI.runtime-7.1.0-LICENSE.txt'), (Join-Path $workerOutput 'csharpdb-migration-sqlserver-worker.deps.json') ) @@ -299,7 +300,7 @@ if ($missing.Count -gt 0) { $sniLicense = Join-Path ` $workerOutput ` - 'licenses/Microsoft.Data.SqlClient.SNI.runtime-6.0.3-LICENSE.txt' + 'licenses/Microsoft.Data.SqlClient.SNI.runtime-7.1.0-LICENSE.txt' $expectedSniLicenseHash = '9335E8BAD875DD7BE4EEBD55D2335EB6433D1CEA61AADB3817AF7807BEF8932A' $actualSniLicenseHash = (Get-FileHash ` @@ -309,7 +310,7 @@ if (-not $actualSniLicenseHash.Equals( $expectedSniLicenseHash, [StringComparison]::OrdinalIgnoreCase)) { - throw 'The SQL Server SNI runtime license does not match the reviewed 6.0.3 terms.' + throw 'The SQL Server SNI runtime license does not match the reviewed 7.1.0 terms.' } Assert-ReviewedWorkerPackageClosure ` diff --git a/scripts/Test-SqlServerMigrationIsolation.ps1 b/scripts/Test-SqlServerMigrationIsolation.ps1 index d24dfc5a..188a53e1 100644 --- a/scripts/Test-SqlServerMigrationIsolation.ps1 +++ b/scripts/Test-SqlServerMigrationIsolation.ps1 @@ -348,17 +348,17 @@ function Assert-ReviewedWorkerPackageClosure { ) $expectedPackages = @( - 'Microsoft.Bcl.Cryptography/9.0.13' - 'Microsoft.Data.SqlClient.Extensions.Abstractions/7.0.3' - 'Microsoft.Data.SqlClient.Internal.Logging/7.0.3' - 'Microsoft.Data.SqlClient.SNI.runtime/6.0.3' - 'Microsoft.Data.SqlClient/7.0.3' - 'Microsoft.Extensions.Caching.Abstractions/9.0.13' - 'Microsoft.Extensions.Caching.Memory/9.0.13' + 'Microsoft.Bcl.Cryptography/9.0.18' + 'Microsoft.Data.SqlClient.Extensions.Abstractions/7.1.1' + 'Microsoft.Data.SqlClient.Internal.Logging/7.1.1' + 'Microsoft.Data.SqlClient.SNI.runtime/7.1.0' + 'Microsoft.Data.SqlClient/7.1.1' + 'Microsoft.Extensions.Caching.Abstractions/9.0.18' + 'Microsoft.Extensions.Caching.Memory/9.0.18' 'Microsoft.Extensions.DependencyInjection.Abstractions/10.0.0' 'Microsoft.Extensions.Logging.Abstractions/10.0.0' - 'Microsoft.Extensions.Options/9.0.13' - 'Microsoft.Extensions.Primitives/9.0.13' + 'Microsoft.Extensions.Options/9.0.18' + 'Microsoft.Extensions.Primitives/9.0.18' 'Microsoft.IdentityModel.Abstractions/8.16.0' 'Microsoft.IdentityModel.JsonWebTokens/8.16.0' 'Microsoft.IdentityModel.Logging/8.16.0' @@ -366,12 +366,13 @@ function Assert-ReviewedWorkerPackageClosure { 'Microsoft.IdentityModel.Protocols/8.16.0' 'Microsoft.IdentityModel.Tokens/8.16.0' 'Microsoft.SqlServer.Server/1.0.0' - 'Microsoft.SqlServer.TransactSql.ScriptDom/180.107.0' - 'System.Configuration.ConfigurationManager/9.0.13' - 'System.Diagnostics.EventLog/9.0.13' + 'Microsoft.SqlServer.TransactSql.ScriptDom/180.117.0' + 'System.Configuration.ConfigurationManager/9.0.18' + 'System.Diagnostics.EventLog/9.0.18' 'System.IdentityModel.Tokens.Jwt/8.16.0' - 'System.Security.Cryptography.Pkcs/9.0.13' - 'System.Security.Cryptography.ProtectedData/9.0.13' + 'System.Security.Cryptography.Pkcs/9.0.18' + 'System.Security.Cryptography.ProtectedData/9.0.18' + 'System.Threading.RateLimiting/9.0.18' ) | Sort-Object $dependencies = [System.IO.File]::ReadAllText($DependencyPath) | @@ -460,7 +461,7 @@ try { (Join-Path $workerOutput 'Microsoft.Data.SqlClient.dll'), (Join-Path $workerOutput 'Microsoft.SqlServer.TransactSql.ScriptDom.dll'), (Join-Path $workerOutput 'THIRD-PARTY-NOTICES.md'), - (Join-Path $workerOutput 'licenses/Microsoft.Data.SqlClient.SNI.runtime-6.0.3-LICENSE.txt'), + (Join-Path $workerOutput 'licenses/Microsoft.Data.SqlClient.SNI.runtime-7.1.0-LICENSE.txt'), (Join-Path $workerOutput 'csharpdb-migration-sqlserver-worker.deps.json') ) foreach ($requiredFile in $requiredWorkerFiles) { @@ -471,7 +472,7 @@ try { $sniLicense = Join-Path ` $workerOutput ` - 'licenses/Microsoft.Data.SqlClient.SNI.runtime-6.0.3-LICENSE.txt' + 'licenses/Microsoft.Data.SqlClient.SNI.runtime-7.1.0-LICENSE.txt' $expectedSniLicenseHash = '9335E8BAD875DD7BE4EEBD55D2335EB6433D1CEA61AADB3817AF7807BEF8932A' $actualSniLicenseHash = (Get-FileHash ` @@ -481,7 +482,7 @@ try { $expectedSniLicenseHash, [StringComparison]::OrdinalIgnoreCase)) { - throw 'The published SNI runtime license does not match the reviewed 6.0.3 terms.' + throw 'The published SNI runtime license does not match the reviewed 7.1.0 terms.' } Assert-ReviewedWorkerPackageClosure ` diff --git a/src/CSharpDB.Admin.Desktop/CSharpDB.Admin.Desktop.csproj b/src/CSharpDB.Admin.Desktop/CSharpDB.Admin.Desktop.csproj index 49c49d22..575247ed 100644 --- a/src/CSharpDB.Admin.Desktop/CSharpDB.Admin.Desktop.csproj +++ b/src/CSharpDB.Admin.Desktop/CSharpDB.Admin.Desktop.csproj @@ -13,7 +13,7 @@ - + diff --git a/src/CSharpDB.Api/CSharpDB.Api.csproj b/src/CSharpDB.Api/CSharpDB.Api.csproj index 287fa215..e4adc7a6 100644 --- a/src/CSharpDB.Api/CSharpDB.Api.csproj +++ b/src/CSharpDB.Api/CSharpDB.Api.csproj @@ -12,13 +12,13 @@ - - + + - - - + + + diff --git a/src/CSharpDB.Client/CSharpDB.Client.csproj b/src/CSharpDB.Client/CSharpDB.Client.csproj index b04797b4..711808e0 100644 --- a/src/CSharpDB.Client/CSharpDB.Client.csproj +++ b/src/CSharpDB.Client/CSharpDB.Client.csproj @@ -23,10 +23,10 @@ - - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/CSharpDB.Data/CSharpDbConnectionPool.cs b/src/CSharpDB.Data/CSharpDbConnectionPool.cs index 5c4aec2e..49fd1b89 100644 --- a/src/CSharpDB.Data/CSharpDbConnectionPool.cs +++ b/src/CSharpDB.Data/CSharpDbConnectionPool.cs @@ -2147,9 +2147,7 @@ private async ValueTask ExecuteReadAsync( Database.ReaderSession readerSession = _transactionSnapshotRowCounts is null ? database.CreateReaderSession() - : database.CreateReaderSession( - _transactionSnapshotRowCounts, - allowCurrentCatalogRowCounts: false); + : database.CreateReaderSession(_transactionSnapshotRowCounts); TrackReaderSession(sessionId, readerSession); try { diff --git a/src/CSharpDB.Data/PreparedStatementTemplate.cs b/src/CSharpDB.Data/PreparedStatementTemplate.cs index 08dc709c..76ce7039 100644 --- a/src/CSharpDB.Data/PreparedStatementTemplate.cs +++ b/src/CSharpDB.Data/PreparedStatementTemplate.cs @@ -1,4 +1,5 @@ using System.Globalization; +using CSharpDB.Execution; using CSharpDB.Primitives; using CSharpDB.Sql; using SqlBitString = CSharpDB.Client.Models.SqlBitString; @@ -13,6 +14,7 @@ internal sealed class PreparedStatementTemplate private readonly bool _containsParameters; private readonly PreparedSimpleInsertTemplate? _simpleInsertTemplate; private readonly BoundStatementCache? _boundStatementCache; + private readonly PreparedSelectTemplate? _preparedSelectTemplate; private PreparedStatementTemplate( Statement template, @@ -23,6 +25,8 @@ private PreparedStatementTemplate( _parameterNames = parameterNames; _containsParameters = parameterNames.Length > 0; _simpleInsertTemplate = simpleInsertTemplate; + _preparedSelectTemplate = parameterNames.Length <= 16 + ? PreparedSelectTemplate.TryCreate(template, parameterNames) : null; _boundStatementCache = _containsParameters && simpleInsertTemplate == null ? new BoundStatementCache(DefaultBoundStatementCacheCapacity) : null; @@ -54,13 +58,35 @@ internal Statement Bind(CSharpDbParameterCollection parameters) if (!_containsParameters) return _template; + if (_preparedSelectTemplate is { } select) + { + Span values = stackalloc long[_parameterNames.Length]; + if (TryBindIntegerValues(parameters, values)) + return select.Bind(values); + } + var cache = _boundStatementCache; if (cache != null) - return cache.GetOrAdd(_parameterNames, parameters, () => BindStatement(_template, parameters)); + return cache.GetOrAdd(_parameterNames, parameters, _template); return BindStatement(_template, parameters); } + private bool TryBindIntegerValues(CSharpDbParameterCollection parameters, Span values) + { + for (int i = 0; i < _parameterNames.Length; i++) + { + string name = _parameterNames[i]; + if (!parameters.TryGetParameter(name.AsSpan(), out var parameter)) + throw new InvalidOperationException($"Parameter '@{name}' was not supplied."); + if (parameter.DbType == System.Data.DbType.Boolean || + parameter.Value is not (long or int or short or byte or sbyte or uint or ushort or ulong)) + return false; + values[i] = ToDbValue(parameter.Value).AsInteger; + } + return true; + } + private static bool TryCreateSimpleInsertTemplate( Statement statement, out PreparedSimpleInsertTemplate? simpleInsertTemplate) @@ -1278,10 +1304,10 @@ internal BoundStatementCache(int capacity) internal Statement GetOrAdd( string[] parameterNames, CSharpDbParameterCollection parameters, - Func factory) + Statement template) { if (_capacity == 0) - return factory(); + return BindStatement(template, parameters); lock (_gate) { @@ -1301,7 +1327,7 @@ internal Statement GetOrAdd( } } - var created = factory(); + var created = BindStatement(template, parameters); var capturedValues = CaptureValues(parameterNames, parameters); lock (_gate) diff --git a/src/CSharpDB.Engine/Database.ReaderCatalog.cs b/src/CSharpDB.Engine/Database.ReaderCatalog.cs new file mode 100644 index 00000000..bc75b8cc --- /dev/null +++ b/src/CSharpDB.Engine/Database.ReaderCatalog.cs @@ -0,0 +1,102 @@ +using CSharpDB.Storage.Catalog; +using CSharpDB.Storage.Paging; +using CSharpDB.Storage.Wal; + +namespace CSharpDB.Engine; + +public sealed partial class Database +{ + private readonly object _readerCatalogGate = new(); + private ReaderCatalogState? _readerCatalogState; + private int _catalogPublications; + private bool _readerCatalogCopyUnsafe; + + internal sealed class ReaderCatalogState(WalSnapshot snapshot, CatalogReadSnapshot? catalog) + { + internal readonly WalSnapshot Snapshot = snapshot; + internal CatalogReadSnapshot? Catalog = catalog; + private SemaphoreSlim? _loadGate; + internal SemaphoreSlim LoadGate => LazyInitializer.EnsureInitialized(ref _loadGate, static () => new(1, 1)); + } + + private ReaderSession CreateReaderSessionCore(IReadOnlyDictionary? snapshotRowCounts = null) + { + lock (_readerCatalogGate) + { + WalSnapshot snapshot = _pager.AcquireReaderSnapshot(); + try + { + ReaderCatalogState? state = _readerCatalogState; + if (state is null || !state.Snapshot.SharesPageState(snapshot)) + { + CatalogReadSnapshot? catalog = null; + // Neither gate waits: an active writer uses the snapshot-backed fallback. + // The publication barrier covers WAL publication through shared-catalog sync. + if (_catalogPublications == 0 && !_readerCatalogCopyUnsafe && _writeOperationGate.Wait(0)) + { + try + { + if (_sharedStateGate.Wait(0)) + { + try + { + if (!_inTransaction && _pager.CanCaptureCommittedCatalog(snapshot)) + catalog = _catalog.CaptureReadSnapshot(state?.Catalog); + } + finally { _sharedStateGate.Release(); } + } + } + finally { _writeOperationGate.Release(); } + } + state = new ReaderCatalogState(snapshot, catalog); + _readerCatalogState = state; + } + + return new ReaderSession(this, _pager, state, _recordSerializer, snapshot, + _statementCache, _functions, _planner.AdaptiveQueryReoptimization, + _planner.WindowExecution, _queryObservability, _queryPlanRuntimeObserver, snapshotRowCounts); + } + catch + { + _pager.ReleaseReaderSnapshot(snapshot); + throw; + } + } + } + + private async ValueTask LoadReaderCatalogAsync( + ReaderCatalogState state, WalSnapshot snapshot, CancellationToken ct) + { + CatalogReadSnapshot? catalog = Volatile.Read(ref state.Catalog); + if (catalog is not null) return catalog; + await state.LoadGate.WaitAsync(ct); + try + { + catalog = state.Catalog; + if (catalog is not null) return catalog; + using Pager snapshotPager = _pager.CreateSnapshotReader(snapshot); + catalog = await SchemaCatalog.LoadReadSnapshotAsync(snapshotPager, _schemaSerializer, + _indexProvider, _catalogStore, _advisoryStatisticsPersistenceMode, ct); + Volatile.Write(ref state.Catalog, catalog); + return catalog; + } + finally { state.LoadGate.Release(); } + } + + internal void BeginReaderCatalogPublication() + { + lock (_readerCatalogGate) _catalogPublications++; + } + + internal void EndReaderCatalogPublication(bool synchronized) + { + lock (_readerCatalogGate) + { + _catalogPublications--; + // A failed commit can leave live metadata ahead of committed pages. Subsequent + // reader states load from their snapshot rather than trusting recovery timing. + if (!synchronized) _readerCatalogCopyUnsafe = true; + } + } + +} diff --git a/src/CSharpDB.Engine/Database.cs b/src/CSharpDB.Engine/Database.cs index c1560f1e..68e6871a 100644 --- a/src/CSharpDB.Engine/Database.cs +++ b/src/CSharpDB.Engine/Database.cs @@ -39,7 +39,7 @@ internal readonly record struct MutationTargetCollectionDiagnosticsSnapshot( /// /// Top-level entry point for the CSharpDB embedded database engine. /// -public sealed class Database : IAsyncDisposable +public sealed partial class Database : IAsyncDisposable { private const int DefaultStatementCacheCapacity = 512; private const int DefaultImplicitConflictRetries = 10; @@ -609,8 +609,9 @@ private void ObserveSharedNextRowId(string tableName, long nextRowId) { _sharedNextRowIdHints.AddOrUpdate( tableName, - nextRowId, - (_, existing) => Math.Max(existing, nextRowId)); + static (_, observed) => observed, + static (_, existing, observed) => Math.Max(existing, observed), + nextRowId); } } @@ -2503,30 +2504,16 @@ internal async ValueTask SaveToFileAsync( /// Caller must dispose the returned ReaderSession when done. /// public ReaderSession CreateReaderSession() - => CreateReaderSession( - CaptureSnapshotRowCounts(), - allowCurrentCatalogRowCounts: true); + => CreateReaderSessionCore(); internal ReaderSession CreateReaderSession( - IReadOnlyDictionary snapshotRowCounts, - bool allowCurrentCatalogRowCounts) + IReadOnlyDictionary snapshotRowCounts) { ArgumentNullException.ThrowIfNull(snapshotRowCounts); - var snapshot = _pager.AcquireReaderSnapshot(); - return new ReaderSession( - _pager, - _catalog, - _recordSerializer, - snapshot, - _statementCache, - _functions, - _planner.AdaptiveQueryReoptimization, - _planner.WindowExecution, - _queryObservability, - _queryPlanRuntimeObserver, - snapshotRowCounts, - allowCurrentCatalogRowCounts); + // The pool holds its dispatch gate and captured these exact counts before beginning + // its legacy transaction. Roots and schema still come from the acquired WAL state. + return CreateReaderSessionCore(snapshotRowCounts); } internal IReadOnlyDictionary CaptureReaderSnapshotRowCounts() => @@ -3700,15 +3687,15 @@ public sealed class ReaderSession : IDisposable ]; private readonly Pager _pager; - private readonly SchemaCatalog _catalog; + private readonly Database _database; + private readonly ReaderCatalogState _catalogState; + private CatalogReadSnapshot? _catalog; private readonly IRecordSerializer _recordSerializer; private readonly IRecordSerializer? _collectionReadSerializer; private readonly DbFunctionRegistry _functions; private readonly Func _releaseActiveQueryCallback; private readonly StatementCache _statementCache; private readonly WalSnapshot _snapshot; - private readonly IReadOnlyDictionary _snapshotRowCounts; - private readonly bool _allowCurrentCatalogRowCounts; private readonly AdaptiveQueryReoptimizationOptions _adaptiveQueryReoptimization; private readonly WindowExecutionOptions _windowExecution; private readonly QueryObservability? _queryObservability; @@ -3717,12 +3704,18 @@ public sealed class ReaderSession : IDisposable private QueryPlanner? _planner; private string? _lastSql; private Statement? _lastParsedStatement; + private TableSchema? _lastReadTableSchema; + private uint _lastReadTableRoot; + private long? _lastReadTableCount; private bool _disposed; private int _activeQuery; + private readonly IReadOnlyDictionary? _snapshotRowCounts; + internal ReaderSession( + Database database, Pager pager, - SchemaCatalog catalog, + ReaderCatalogState catalogState, IRecordSerializer recordSerializer, WalSnapshot snapshot, StatementCache statementCache, @@ -3731,11 +3724,13 @@ internal ReaderSession( WindowExecutionOptions windowExecution, QueryObservability? queryObservability, IQueryPlanRuntimeObserver? queryPlanRuntimeObserver, - IReadOnlyDictionary snapshotRowCounts, - bool allowCurrentCatalogRowCounts) + IReadOnlyDictionary? snapshotRowCounts) { + _database = database; _pager = pager; - _catalog = catalog; + _catalogState = catalogState; + _catalog = catalogState.Catalog; + _snapshotRowCounts = snapshotRowCounts; _recordSerializer = recordSerializer; _collectionReadSerializer = recordSerializer is DefaultRecordSerializer ? new CollectionAwareRecordSerializer(recordSerializer) @@ -3748,8 +3743,6 @@ internal ReaderSession( _windowExecution = windowExecution; _queryObservability = queryObservability; _queryPlanRuntimeObserver = queryPlanRuntimeObserver; - _snapshotRowCounts = snapshotRowCounts; - _allowCurrentCatalogRowCounts = allowCurrentCatalogRowCounts; } /// @@ -3826,8 +3819,16 @@ private ValueTask ExecuteReadCoreAsync( AcquireActiveRead(); + return ExecuteAcquiredReadAsync(stmt, ct); + } + + private ValueTask ExecuteAcquiredReadAsync(Statement stmt, CancellationToken ct) + { try { + if (_catalog is null) + return InitializeCatalogAndExecuteReadAsync(stmt, ct); + if (stmt is SelectStatement select) { if (TryExecuteCountStarFastPath(select, out QueryResult fastCountResult)) @@ -3890,6 +3891,11 @@ internal RetainedDatabaseSnapshotTableReader OpenTableReader( ArgumentException.ThrowIfNullOrWhiteSpace(tableName); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxEncodedRowBytes); + // The synchronous physical-reader API rarely needs this fallback. Run the async + // snapshot load outside a caller's synchronization context before blocking. + _catalog ??= Task.Run(async () => await _database.LoadReaderCatalogAsync( + _catalogState, _snapshot, CancellationToken.None)).GetAwaiter().GetResult(); + if (IsReservedPhysicalTableName(tableName)) { throw new InvalidOperationException( @@ -3909,7 +3915,7 @@ internal RetainedDatabaseSnapshotTableReader OpenTableReader( AcquireActiveRead(); try { - var tree = _catalog.GetTableTree(schema.TableName, GetOrCreateSnapshotPager()); + var tree = new BTree(GetOrCreateSnapshotPager(), _catalog.GetTableRootPage(schema.TableName), schema.TableName); return new RetainedDatabaseSnapshotTableReader( schema, tree.CreateCursor(maxEncodedRowBytes), @@ -3949,6 +3955,20 @@ private ValueTask ReleaseActiveQueryAsync() return ValueTask.CompletedTask; } + private async ValueTask InitializeCatalogAndExecuteReadAsync(Statement stmt, CancellationToken ct) + { + try + { + _catalog = await _database.LoadReaderCatalogAsync(_catalogState, _snapshot, ct); + return await ExecuteAcquiredReadAsync(stmt, ct); + } + catch + { + Volatile.Write(ref _activeQuery, 0); + throw; + } + } + private async ValueTask CompleteReadWithPrimaryKeyFastPathAsync( Statement stmt, ValueTask fastLookupTask, @@ -3999,8 +4019,6 @@ private bool TryExecuteCountStarFastPath(SelectStatement stmt, out QueryResult r if (stmt.From is not SimpleTableRef simpleRef) return false; - if (IsSystemCatalogTable(simpleRef.TableName) || _catalog.IsView(simpleRef.TableName)) - return false; if (stmt.Where != null || stmt.GroupBy != null || stmt.Having != null) return false; if (stmt.OrderBy is { Count: > 0 }) @@ -4015,27 +4033,22 @@ private bool TryExecuteCountStarFastPath(SelectStatement stmt, out QueryResult r return false; if (!string.Equals(func.FunctionName, "COUNT", StringComparison.OrdinalIgnoreCase)) return false; - if (_catalog.GetTable(simpleRef.TableName) == null) + if (!TryCacheReadTable(simpleRef.TableName)) return false; ColumnDefinition[] outputSchema = stmt.Columns[0].Alias is { Length: > 0 } alias ? [new ColumnDefinition { Name = alias, Type = DbType.Integer, Nullable = false }] : DefaultCountStarOutputSchema; - if (_snapshotRowCounts.TryGetValue(simpleRef.TableName, out long rowCount)) - { - result = QueryResult.FromSyncScalar(DbValue.FromInteger(rowCount), outputSchema); - return true; - } - - if (_allowCurrentCatalogRowCounts && - _catalog.TryGetExactTableRowCount(simpleRef.TableName, out rowCount)) + long rowCount; + if ((_snapshotRowCounts is not null && _snapshotRowCounts.TryGetValue(simpleRef.TableName, out rowCount)) || + TryGetCachedReadTableCount(simpleRef.TableName, out rowCount)) { result = QueryResult.FromSyncScalar(DbValue.FromInteger(rowCount), outputSchema); return true; } - var tableTree = _catalog.GetTableTree(simpleRef.TableName, GetOrCreateSnapshotPager()); + var tableTree = new BTree(GetOrCreateSnapshotPager(), _lastReadTableRoot, simpleRef.TableName); result = new QueryResult(new CountStarTableOperator(tableTree, outputSchema, ignoreCachedCount: true)); return true; } @@ -4046,16 +4059,14 @@ private bool TryExecuteCountStarFastPath(SelectStatement stmt, out QueryResult r return null; if (stmt.From is not SimpleTableRef simpleRef) return null; - if (IsSystemCatalogTable(simpleRef.TableName) || _catalog.IsView(simpleRef.TableName)) - return null; if (stmt.Where == null || stmt.GroupBy != null || stmt.Having != null) return null; if (stmt.OrderBy is { Count: > 0 } || stmt.Limit.HasValue || stmt.Offset.HasValue) return null; - var schema = _catalog.GetTable(simpleRef.TableName); - if (schema == null) + if (!TryCacheReadTable(simpleRef.TableName)) return null; + TableSchema schema = _lastReadTableSchema!; int pkIndex = schema.PrimaryKeyColumnIndex; if (pkIndex < 0 || pkIndex >= schema.Columns.Count || schema.Columns[pkIndex].Type != DbType.Integer) @@ -4067,7 +4078,7 @@ private bool TryExecuteCountStarFastPath(SelectStatement stmt, out QueryResult r if (!TryBuildProjection(stmt.Columns, schema, out var projectionColumnIndices, out var outputColumns, out bool selectStar)) return null; - var tableTree = new BTree(_pager, _catalog.GetTableRootPage(simpleRef.TableName)); + var tableTree = new BTree(_pager, _lastReadTableRoot); ReadOnlyMemory? payload = tableTree.TryFindSnapshotCachedMemory( lookupValue, _snapshot, @@ -4080,7 +4091,14 @@ private bool TryExecuteCountStarFastPath(SelectStatement stmt, out QueryResult r if (selectStar) { var serializer = GetReadSerializer(schema); - return QueryResult.FromSyncLookup(serializer.Decode(payload.Value.Span), outputColumns); + var row = serializer.Decode(payload.Value.Span); + if (row.Length < outputColumns.Length) + { + int storedCount = row.Length; + Array.Resize(ref row, outputColumns.Length); + Array.Fill(row, DbValue.Null, storedCount, row.Length - storedCount); + } + return QueryResult.FromSyncLookup(row, outputColumns); } if (IsPrimaryKeyOnlyProjection(projectionColumnIndices, pkIndex)) @@ -4094,25 +4112,65 @@ private bool TryExecuteCountStarFastPath(SelectStatement stmt, out QueryResult r var decoded = GetReadSerializer(schema).Decode(payload.Value.Span); var projected = new DbValue[projectionColumnIndices.Length]; for (int i = 0; i < projectionColumnIndices.Length; i++) - projected[i] = decoded[projectionColumnIndices[i]]; + projected[i] = projectionColumnIndices[i] < decoded.Length ? decoded[projectionColumnIndices[i]] : DbValue.Null; return QueryResult.FromSyncLookup(projected, outputColumns); } + private bool TryCacheReadTable(string tableName) + { + // Schema and root stay fixed for this reader's lifetime. Repeated point reads + // and COUNTs need no further immutable-map lookups for the same table. + if (_lastReadTableSchema is not null && + string.Equals(_lastReadTableSchema.TableName, tableName, StringComparison.OrdinalIgnoreCase)) + return true; + + if (IsSystemCatalogTable(tableName) || _catalog!.IsView(tableName) || + !_catalog.TryGetReadTable(tableName, out CatalogReadTable table)) + return false; + + _lastReadTableSchema = table.Schema; + _lastReadTableRoot = table.RootPage; + _lastReadTableCount = null; + return true; + } + + private bool TryGetCachedReadTableCount(string tableName, out long count) + { + if (_lastReadTableCount.HasValue) + { + count = _lastReadTableCount.Value; + return true; + } + if (!_catalog!.TryGetExactTableRowCount(tableName, out count)) + return false; + + _lastReadTableCount = count; + return true; + } + private Pager GetOrCreateSnapshotPager() - => _snapshotPager ??= _pager.CreateSnapshotReader(_snapshot); + { + if (_snapshotPager is null) + { + _snapshotPager = _pager.CreateSnapshotReader(_snapshot); + _catalog!.ApplyHeader(_snapshotPager); + } + return _snapshotPager; + } private QueryPlanner CreatePlanner() { var planner = new QueryPlanner( GetOrCreateSnapshotPager(), - _catalog, + _catalog!.CreateReaderView(GetOrCreateSnapshotPager()), _recordSerializer, functions: _functions, adaptiveQueryReoptimization: _adaptiveQueryReoptimization, windowExecution: _windowExecution) { PlanRuntimeObserver = _queryPlanRuntimeObserver, + EnablePreparedPrimaryKeyRanges = true, }; return planner; } diff --git a/src/CSharpDB.Engine/README.md b/src/CSharpDB.Engine/README.md index 75f0cb85..b8592a30 100644 --- a/src/CSharpDB.Engine/README.md +++ b/src/CSharpDB.Engine/README.md @@ -397,7 +397,9 @@ Console.WriteLine($"Live row count: {liveResult.Current[0].AsInteger}"); ``` `ReaderSession` gives you a stable snapshot from the moment it is created, even -while the writer keeps committing changes. Dispose each `QueryResult` before +while the writer keeps committing changes. Its catalog metadata belongs to that +same snapshot, so later commits and checkpoints do not replace the reader's +schema or table roots. Dispose each `QueryResult` before executing the next query on the same reader session. Reuse the same `ReaderSession` for a burst of related reads when possible. The diff --git a/src/CSharpDB.Engine/WriteTransaction.cs b/src/CSharpDB.Engine/WriteTransaction.cs index a283c131..2932f57b 100644 --- a/src/CSharpDB.Engine/WriteTransaction.cs +++ b/src/CSharpDB.Engine/WriteTransaction.cs @@ -201,6 +201,25 @@ private async ValueTask CommitCoreAsync(CancellationToken ct) "The transaction was rolled back because an earlier write failed."); } + _database.BeginReaderCatalogPublication(); + bool synchronized = false; + try + { + await CommitPublishingCoreAsync(ct); + synchronized = true; + } + finally + { + // Native conflict validation rejects before appending any WAL frames. + // Its isolated catalog never changed the shared catalog, so future + // readers can still capture it once other publications finish. + _database.EndReaderCatalogPublication( + synchronized || _storageTransaction.State.CommitRejectedBeforePublication); + } + } + + private async ValueTask CommitPublishingCoreAsync(CancellationToken ct) + { PagerCommitResult commit; KeyValuePair[] committedNextRowIds; KeyValuePair[] committedTableRowCountDeltas; diff --git a/src/CSharpDB.Execution/Operators.cs b/src/CSharpDB.Execution/Operators.cs index 2ebac711..6e1cb0d9 100644 --- a/src/CSharpDB.Execution/Operators.cs +++ b/src/CSharpDB.Execution/Operators.cs @@ -507,7 +507,7 @@ public sealed class TableScanOperator : private bool _hasPreDecodeFilter; private PreDecodeFilterSpec[]? _additionalPreDecodeFilters; private Action? _logicalReadScope; - private RowBatch _currentBatch; + private RowBatch? _currentBatch; public ColumnDefinition[] OutputSchema { get; } public bool ReusesCurrentRowBuffer => _reuseCurrentRowBuffer; @@ -534,7 +534,6 @@ public TableScanOperator( _recordSerializer = recordSerializer ?? new DefaultRecordSerializer(); _estimatedRowCount = estimatedRowCount > 0 ? estimatedRowCount : null; OutputSchema = schema.Columns as ColumnDefinition[] ?? schema.Columns.ToArray(); - _currentBatch = CreateBatch(GetTargetColumnCount()); } /// @@ -621,7 +620,7 @@ public ValueTask OpenAsync(CancellationToken ct = default) : _tree.CreateCursorWithoutLogicalRead(); _currentPayload = ReadOnlyMemory.Empty; _rowBuffer = null; - _currentBatch = CreateBatch(GetTargetColumnCount()); + _currentBatch = null; Current = Array.Empty(); return ValueTask.CompletedTask; } @@ -748,13 +747,13 @@ public async ValueTask MoveNextBatchAsync(CancellationToken ct = default) } bool IBatchOperator.ReusesCurrentBatch => _reuseCurrentBatch; - RowBatch IBatchOperator.CurrentBatch => _currentBatch; + RowBatch IBatchOperator.CurrentBatch => _currentBatch ??= CreateBatch(GetTargetColumnCount()); public void SetReuseCurrentBatch(bool reuse) { _reuseCurrentBatch = reuse; if (!reuse) - _currentBatch = CreateBatch(GetTargetColumnCount()); + _currentBatch = null; } private void EnsureRowBuffer(int columnCount) @@ -770,7 +769,7 @@ private int GetTargetColumnCount() private RowBatch EnsureBatch(int columnCount) { - if (_currentBatch.ColumnCount != columnCount) + if (_currentBatch is null || _currentBatch.ColumnCount != columnCount) _currentBatch = CreateBatch(columnCount); return _currentBatch; @@ -3405,40 +3404,103 @@ public sealed class LimitOperator : IPhysicalOperatorChildren { private const int DefaultBatchSize = 64; - private const int SmallLimitRowModeThreshold = 4; + internal const int SmallLimitRowModeThreshold = 4; private readonly IOperator _source; private readonly int _limit; + private readonly bool _hasBatchBackedRowSource; private int _count; private bool _reuseCurrentBatch = true; - private RowBatch _currentBatch; + private RowBatch? _currentBatch; + private RowBatch? _ownedBatch; + private DbValue[]? _currentRowFromBatch; private int _sourceBatchRowIndex = -1; public ColumnDefinition[] OutputSchema => _source.OutputSchema; public bool ReusesCurrentRowBuffer => _source.ReusesCurrentRowBuffer; - public DbValue[] Current => _source.Current; + public DbValue[] Current => _currentRowFromBatch ?? _source.Current; public int? EstimatedRowCount => _limit >= 0 ? _limit : 0; IOperator IUnaryOperatorSource.Source => _source; IReadOnlyList IPhysicalOperatorChildren.PhysicalChildren => [_source]; + private bool HasBatchBackedRowSource => _hasBatchBackedRowSource; + public LimitOperator(IOperator source, int limit) { _source = PhysicalPlanCapture.WrapIfActive(source); _limit = limit; - _currentBatch = CreateBatch(_source.OutputSchema.Length); + _hasBatchBackedRowSource = _source is IBatchBackedRowOperator and not IBatchOperator; } public async ValueTask OpenAsync(CancellationToken ct = default) { _count = 0; - _currentBatch = CreateBatch(_source.OutputSchema.Length); + _currentBatch = null; + _ownedBatch = null; + _currentRowFromBatch = null; _sourceBatchRowIndex = -1; await _source.OpenAsync(ct); } - public async ValueTask MoveNextAsync(CancellationToken ct = default) + public ValueTask MoveNextAsync(CancellationToken ct = default) + { + if (_count >= _limit) return ValueTask.FromResult(false); + + // Keep ordinary row reads independent of the larger mixed-traversal state. + return _sourceBatchRowIndex < 0 && !HasBatchBackedRowSource + ? MoveNextFromRowSourceAsync(ct) + : MoveNextFromBatchSourceAsync(ct); + } + + private async ValueTask MoveNextFromRowSourceAsync(CancellationToken ct) { - if (_count >= _limit) return false; + _currentRowFromBatch = null; + if (!await _source.MoveNextAsync(ct)) return false; + _count++; + return true; + } + + private async ValueTask MoveNextFromBatchSourceAsync(CancellationToken ct) + { + bool batchBackedRowSource = HasBatchBackedRowSource; + var batchSource = _sourceBatchRowIndex >= 0 || batchBackedRowSource + ? BatchSourceHelper.TryGetBatchSource(_source) + : null; + if (batchSource != null) + { + RowBatch sourceBatch = batchSource.CurrentBatch; + // An adapter has a separate row position. Keep all traversal through + // this operator's batch cursor so switching modes cannot replay rows. + while (batchBackedRowSource && _sourceBatchRowIndex + 1 >= sourceBatch.Count) + { + if (!await batchSource.MoveNextBatchAsync(ct)) + return false; + + sourceBatch = batchSource.CurrentBatch; + _sourceBatchRowIndex = -1; + } + + // A physical source already advanced past every row in this batch. + // Consume its buffered remainder before delegating another row read. + if (_sourceBatchRowIndex + 1 < sourceBatch.Count) + { + ct.ThrowIfCancellationRequested(); + if (!_source.ReusesCurrentRowBuffer || + _currentRowFromBatch is null || _currentRowFromBatch.Length != sourceBatch.ColumnCount) + { + _currentRowFromBatch = sourceBatch.ColumnCount == 0 + ? Array.Empty() + : new DbValue[sourceBatch.ColumnCount]; + } + + sourceBatch.CopyRowTo(_sourceBatchRowIndex + 1, _currentRowFromBatch); + _sourceBatchRowIndex++; + _count++; + return true; + } + } + + _currentRowFromBatch = null; if (!await _source.MoveNextAsync(ct)) return false; _count++; return true; @@ -3448,6 +3510,7 @@ public async ValueTask MoveNextAsync(CancellationToken ct = default) public void SetReuseCurrentRowBuffer(bool reuse) { + _currentRowFromBatch = null; if (_source is IRowBufferReuseController controller) controller.SetReuseCurrentRowBuffer(reuse); } @@ -3458,7 +3521,7 @@ public async ValueTask MoveNextBatchAsync(CancellationToken ct = default) return false; int remaining = _limit - _count; - if (remaining <= SmallLimitRowModeThreshold && _sourceBatchRowIndex < 0) + if (remaining <= SmallLimitRowModeThreshold && _sourceBatchRowIndex < 0 && !HasBatchBackedRowSource) { var smallBatch = _reuseCurrentBatch ? EnsureBatch(_source.OutputSchema.Length) : CreateBatch(_source.OutputSchema.Length); smallBatch.Reset(); @@ -3491,73 +3554,88 @@ public async ValueTask MoveNextBatchAsync(CancellationToken ct = default) } bool IBatchOperator.ReusesCurrentBatch => _reuseCurrentBatch; - RowBatch IBatchOperator.CurrentBatch => _currentBatch; + RowBatch IBatchOperator.CurrentBatch => _currentBatch ??= EnsureBatch(_source.OutputSchema.Length); public void SetReuseCurrentBatch(bool reuse) { _reuseCurrentBatch = reuse; if (!reuse) - _currentBatch = CreateBatch(_source.OutputSchema.Length); + { + _currentBatch = null; + _ownedBatch = null; + } } private async ValueTask MoveNextBatchFromBatchSourceAsync(IBatchOperator batchSource, CancellationToken ct) { RowBatch sourceBatch = batchSource.CurrentBatch; - int columnCount = sourceBatch.ColumnCount; - var batch = _reuseCurrentBatch ? EnsureBatch(columnCount) : CreateBatch(columnCount); - batch.Reset(); + // Honor an empty buffer already exposed to a caller, but otherwise defer the + // owned buffer until rows actually need copying. + RowBatch? batch = _reuseCurrentBatch && + _ownedBatch is { Count: 0 } && ReferenceEquals(_currentBatch, _ownedBatch) + ? _ownedBatch + : null; - while (batch.Count < batch.Capacity && _count < _limit) + while (_count < _limit) { - while (_sourceBatchRowIndex + 1 < sourceBatch.Count && batch.Count < batch.Capacity && _count < _limit) - { - _sourceBatchRowIndex++; - int targetRowIndex = batch.Count; - sourceBatch.GetRowSpan(_sourceBatchRowIndex).CopyTo(batch.GetWritableRowSpan(targetRowIndex)); - batch.CommitWrittenRow(targetRowIndex); - _count++; - } - - if (batch.Count >= batch.Capacity || _count >= _limit) - break; - int remaining = _limit - _count; - if (batch.Count == 0 && + if (batch is null && _sourceBatchRowIndex < 0 && sourceBatch.Count > 0 && + sourceBatch.Count <= DefaultBatchSize && sourceBatch.Count <= remaining && (_reuseCurrentBatch || !batchSource.ReusesCurrentBatch)) { + // Forward an intact batch before copying any rows. It is borrowed: + // subsequent fallback writes must use a separate owned buffer. _currentBatch = sourceBatch; _count += sourceBatch.Count; _sourceBatchRowIndex = sourceBatch.Count - 1; return true; } + if (_sourceBatchRowIndex + 1 < sourceBatch.Count) + { + if (batch is null || batch.ColumnCount != sourceBatch.ColumnCount) + { + batch = _reuseCurrentBatch + ? EnsureBatch(sourceBatch.ColumnCount) + : CreateBatch(sourceBatch.ColumnCount); + batch.Reset(); + } + + while (_sourceBatchRowIndex + 1 < sourceBatch.Count && batch.Count < batch.Capacity && _count < _limit) + { + _sourceBatchRowIndex++; + int targetRowIndex = batch.Count; + sourceBatch.GetRowSpan(_sourceBatchRowIndex).CopyTo(batch.GetWritableRowSpan(targetRowIndex)); + batch.CommitWrittenRow(targetRowIndex); + _count++; + } + + if (batch.Count >= batch.Capacity || _count >= _limit) + break; + } + if (!await batchSource.MoveNextBatchAsync(ct)) break; sourceBatch = batchSource.CurrentBatch; - columnCount = sourceBatch.ColumnCount; - if (batch.ColumnCount != columnCount) - { - batch = _reuseCurrentBatch ? EnsureBatch(columnCount) : CreateBatch(columnCount); - batch.Reset(); - } - _sourceBatchRowIndex = -1; } _currentBatch = batch; - return batch.Count > 0; + if (batch is null) + _ownedBatch?.Reset(); + return batch is { Count: > 0 }; } private RowBatch EnsureBatch(int columnCount) { - if (_currentBatch.ColumnCount != columnCount) - _currentBatch = CreateBatch(columnCount); + if (_ownedBatch is null || _ownedBatch.ColumnCount != columnCount) + _ownedBatch = CreateBatch(columnCount); - return _currentBatch; + return _ownedBatch; } private static RowBatch CreateBatch(int columnCount) => new(columnCount, DefaultBatchSize); @@ -12427,7 +12505,7 @@ public sealed class IndexScanOperator : private bool _hasPreDecodeFilter; private PreDecodeFilterSpec[]? _additionalPreDecodeFilters; private ReadOnlyMemory _currentPayload; - private RowBatch _currentBatch; + private RowBatch? _currentBatch; public ColumnDefinition[] OutputSchema { get; } public bool ReusesCurrentRowBuffer => _reuseCurrentRowBuffer; @@ -12476,7 +12554,6 @@ public IndexScanOperator( _expectedKeyTextBytes = BoundColumnAccessHelper.CreateTextLiteralBytes(expectedKeyComponents); } OutputSchema = schema.Columns as ColumnDefinition[] ?? schema.Columns.ToArray(); - _currentBatch = CreateBatch(GetTargetColumnCount()); } /// @@ -12515,7 +12592,7 @@ void IPreDecodeFilterSupport.SetPreDecodeFilter(in PreDecodeFilterSpec filter) public ValueTask OpenAsync(CancellationToken ct = default) { _currentPayload = ReadOnlyMemory.Empty; - _currentBatch = CreateBatch(GetTargetColumnCount()); + _currentBatch = null; if (_indexStore is ICacheAwareIndexStore cacheAware && cacheAware.TryFindCached(_seekValue, out var cachedPayload)) { @@ -12561,13 +12638,13 @@ public async ValueTask MoveNextBatchAsync(CancellationToken ct = default) } bool IBatchOperator.ReusesCurrentBatch => _reuseCurrentBatch; - RowBatch IBatchOperator.CurrentBatch => _currentBatch; + RowBatch IBatchOperator.CurrentBatch => _currentBatch ??= CreateBatch(GetTargetColumnCount()); public void SetReuseCurrentBatch(bool reuse) { _reuseCurrentBatch = reuse; if (!reuse) - _currentBatch = CreateBatch(GetTargetColumnCount()); + _currentBatch = null; } private async ValueTask TryMoveNextPayloadAsync(CancellationToken ct) @@ -12649,7 +12726,7 @@ private int GetTargetColumnCount() private RowBatch EnsureBatch(int columnCount) { - if (_currentBatch.ColumnCount != columnCount) + if (_currentBatch is null || _currentBatch.ColumnCount != columnCount) _currentBatch = CreateBatch(columnCount); return _currentBatch; @@ -13058,7 +13135,7 @@ public sealed class IndexOrderedScanOperator : private bool _hasPreDecodeFilter; private PreDecodeFilterSpec[]? _additionalPreDecodeFilters; private ReadOnlyMemory _currentPayload; - private RowBatch _currentBatch; + private RowBatch? _currentBatch; public ColumnDefinition[] OutputSchema { get; } public bool ReusesCurrentRowBuffer => _reuseCurrentRowBuffer; @@ -13099,7 +13176,6 @@ public IndexOrderedScanOperator( _orderedTextUpperInclusive = orderedTextUpperInclusive; _recordSerializer = recordSerializer ?? new DefaultRecordSerializer(); OutputSchema = schema.Columns as ColumnDefinition[] ?? schema.Columns.ToArray(); - _currentBatch = CreateBatch(GetTargetColumnCount()); } /// @@ -13144,7 +13220,7 @@ public ValueTask OpenAsync(CancellationToken ct = default) _orderedTextRowIds?.Clear(); _currentPayload = ReadOnlyMemory.Empty; _rowBuffer = null; - _currentBatch = CreateBatch(GetTargetColumnCount()); + _currentBatch = null; Current = Array.Empty(); return ValueTask.CompletedTask; } @@ -13206,13 +13282,13 @@ public async ValueTask MoveNextBatchAsync(CancellationToken ct = default) } bool IBatchOperator.ReusesCurrentBatch => _reuseCurrentBatch; - RowBatch IBatchOperator.CurrentBatch => _currentBatch; + RowBatch IBatchOperator.CurrentBatch => _currentBatch ??= CreateBatch(GetTargetColumnCount()); public void SetReuseCurrentBatch(bool reuse) { _reuseCurrentBatch = reuse; if (!reuse) - _currentBatch = CreateBatch(GetTargetColumnCount()); + _currentBatch = null; } private void EnsureRowBuffer(int columnCount) @@ -13326,7 +13402,7 @@ private int GetTargetColumnCount() private RowBatch EnsureBatch(int columnCount) { - if (_currentBatch.ColumnCount != columnCount) + if (_currentBatch is null || _currentBatch.ColumnCount != columnCount) _currentBatch = CreateBatch(columnCount); return _currentBatch; @@ -14321,7 +14397,7 @@ public sealed class IndexOrderedProjectionScanOperator : private ReadOnlyMemory _rowIdPayload; private int _rowIdPayloadOffset; private bool _reuseCurrentBatch = true; - private RowBatch _currentBatch; + private RowBatch? _currentBatch; public ColumnDefinition[] OutputSchema { get; } public bool ReusesCurrentRowBuffer => true; @@ -14343,7 +14419,6 @@ public IndexOrderedProjectionScanOperator( _projectionKinds = BuildProjectionKinds(projectionColumnIndices, primaryKeyColumnIndex, predicateColumnIndex); _requiresRowId = ProjectionRequiresRowId(_projectionKinds); _rowBuffer = outputSchema.Length == 0 ? Array.Empty() : new DbValue[outputSchema.Length]; - _currentBatch = CreateBatch(outputSchema.Length); } public ValueTask OpenAsync(CancellationToken ct = default) @@ -14351,7 +14426,7 @@ public ValueTask OpenAsync(CancellationToken ct = default) _cursor = _indexStore.CreateCursor(_scanRange); _rowIdPayload = ReadOnlyMemory.Empty; _rowIdPayloadOffset = 0; - _currentBatch = CreateBatch(OutputSchema.Length); + _currentBatch = null; Current = Array.Empty(); return ValueTask.CompletedTask; } @@ -14430,13 +14505,13 @@ public async ValueTask MoveNextBatchAsync(CancellationToken ct = default) } bool IBatchOperator.ReusesCurrentBatch => _reuseCurrentBatch; - RowBatch IBatchOperator.CurrentBatch => _currentBatch; + RowBatch IBatchOperator.CurrentBatch => _currentBatch ??= CreateBatch(OutputSchema.Length); public void SetReuseCurrentBatch(bool reuse) { _reuseCurrentBatch = reuse; if (!reuse) - _currentBatch = CreateBatch(OutputSchema.Length); + _currentBatch = null; } private void PopulateCurrent(long keyValue, long rowId) @@ -14492,7 +14567,7 @@ private static bool ProjectionRequiresRowId(ReadOnlySpan +/// Value-independent syntax for the deliberately small prepared integer-index path. +/// Unsupported SQL and parameter types continue through ordinary literal binding. +/// +internal sealed class PreparedSelectTemplate +{ + internal SelectStatement Statement { get; } + internal ColumnRefExpression KeyColumn { get; } + internal string[] ParameterNames { get; } + internal bool HasEquality { get; } + private readonly Term[] _terms; + private readonly List<(long[] Values, SelectStatement Statement)> _fallbackStatements = new(); + private readonly object _fallbackGate = new(); + + private PreparedSelectTemplate(SelectStatement statement, ColumnRefExpression keyColumn, + string[] parameterNames, Term[] terms) + { + Statement = statement; + KeyColumn = keyColumn; + ParameterNames = parameterNames; + _terms = terms; + HasEquality = terms.Any(static term => term.Op == BinaryOp.Equals); + } + + internal static PreparedSelectTemplate? TryCreate(Statement statement, string[] parameterNames) + { + if (parameterNames.Length == 0 || statement is not SelectStatement select || + select.From is not SimpleTableRef || select.Where is null || select.IsDistinct || + select.GroupBy is not null || select.Having is not null || select.WindowDefinitions.Count != 0 || + select.OrderBy is { Count: > 1 } || + select.OrderBy is { Count: 1 } order && (order[0].Descending || order[0].Expression is not ColumnRefExpression) || + !(select.Columns.Count == 1 && select.Columns[0].IsStar) && + select.Columns.Any(c => c.IsStar || c.Expression is not ColumnRefExpression)) + return null; + + // ORDER BY may resolve an output alias before a table column with the same name. + if (select.OrderBy is { Count: 1 } && select.OrderBy[0].Expression is ColumnRefExpression { TableAlias: null } ordered && + select.Columns.Any(c => string.Equals(c.Alias, ordered.ColumnName, StringComparison.OrdinalIgnoreCase))) + return null; + + var terms = new List(); + ColumnRefExpression? keyColumn = null; + bool Collect(Expression expression) + { + if (expression is not BinaryExpression binary) return false; + if (binary.Op == BinaryOp.And) return Collect(binary.Left) && Collect(binary.Right); + if (binary.Op is not (BinaryOp.Equals or BinaryOp.LessThan or BinaryOp.LessOrEqual or + BinaryOp.GreaterThan or BinaryOp.GreaterOrEqual)) return false; + var column = binary.Left as ColumnRefExpression ?? binary.Right as ColumnRefExpression; + if (column is null) return false; + if (keyColumn is not null && + (!string.Equals(keyColumn.ColumnName, column.ColumnName, StringComparison.OrdinalIgnoreCase) || + !string.Equals(keyColumn.TableAlias, column.TableAlias, StringComparison.OrdinalIgnoreCase))) return false; + keyColumn = column; + bool reversed = !ReferenceEquals(binary.Left, column); + Expression value = reversed ? binary.Left : binary.Right; + int slot = -1; + long constant = 0; + if (value is ParameterExpression parameter) + { + slot = Array.FindIndex(parameterNames, name => string.Equals(name, parameter.Name, StringComparison.OrdinalIgnoreCase)); + if (slot < 0) return false; + } + else if (value is LiteralExpression { LiteralType: TokenType.IntegerLiteral, Value: long integer }) + constant = integer; + else return false; + BinaryOp op = reversed ? Reverse(binary.Op) : binary.Op; + terms.Add(new Term(op, slot, constant)); + return true; + } + + return Collect(select.Where) && keyColumn is not null + ? new PreparedSelectTemplate(select, keyColumn, parameterNames, terms.ToArray()) : null; + } + + internal PreparedSelectStatement Bind(ReadOnlySpan values) + { + if (values.Length != ParameterNames.Length) throw new ArgumentException("Parameter count mismatch.", nameof(values)); + return new PreparedSelectStatement(this, values.ToArray()); + } + + internal IndexScanRange GetRange(ReadOnlySpan values) + { + long? lower = null, upper = null; + bool lowerInclusive = true, upperInclusive = true; + foreach (Term term in _terms) + { + long value = term.Slot < 0 ? term.Constant : values[term.Slot]; + if (term.Op is BinaryOp.Equals or BinaryOp.GreaterThan or BinaryOp.GreaterOrEqual) + { + bool inclusive = term.Op != BinaryOp.GreaterThan; + if (lower is null || value > lower) { lower = value; lowerInclusive = inclusive; } + else if (value == lower) lowerInclusive &= inclusive; + } + if (term.Op is BinaryOp.Equals or BinaryOp.LessThan or BinaryOp.LessOrEqual) + { + bool inclusive = term.Op != BinaryOp.LessThan; + if (upper is null || value < upper) { upper = value; upperInclusive = inclusive; } + else if (value == upper) upperInclusive &= inclusive; + } + } + return new IndexScanRange(lower, lowerInclusive, upper, upperInclusive); + } + + internal SelectStatement BindLiterals(long[] values) + { + // Preserve ordinary statement-identity reuse for unsupported catalogs (e.g. a missing index). + // These immutable frames belong to executions, never to a mutable ADO parameter collection. + lock (_fallbackGate) + { + for (int i = 0; i < _fallbackStatements.Count; i++) + { + var entry = _fallbackStatements[i]; + if (!values.AsSpan().SequenceEqual(entry.Values)) continue; + if (i != 0) { _fallbackStatements.RemoveAt(i); _fallbackStatements.Insert(0, entry); } + return entry.Statement; + } + SelectStatement statement = CreateBoundStatement(values); + _fallbackStatements.Insert(0, (values, statement)); + if (_fallbackStatements.Count > 64) _fallbackStatements.RemoveAt(64); + return statement; + } + } + + private SelectStatement CreateBoundStatement(ReadOnlySpan values) + { + // Only used when the current catalog cannot use the prepared index path. + Expression? where = null; + foreach (Term term in _terms) + { + var comparison = new BinaryExpression + { + Left = KeyColumn, Op = term.Op, + Right = new LiteralExpression { LiteralType = TokenType.IntegerLiteral, + Value = term.Slot < 0 ? term.Constant : values[term.Slot] }, + }; + where = where is null ? comparison : new BinaryExpression { Left = where, Op = BinaryOp.And, Right = comparison }; + } + return new SelectStatement + { + Columns = Statement.Columns, From = Statement.From, Where = where, + OrderBy = Statement.OrderBy, Limit = Statement.Limit, Offset = Statement.Offset, + }; + } + + private static BinaryOp Reverse(BinaryOp op) => op switch + { + BinaryOp.LessThan => BinaryOp.GreaterThan, BinaryOp.LessOrEqual => BinaryOp.GreaterOrEqual, + BinaryOp.GreaterThan => BinaryOp.LessThan, BinaryOp.GreaterOrEqual => BinaryOp.LessOrEqual, _ => op, + }; + + private readonly record struct Term(BinaryOp Op, int Slot, long Constant); +} + +/// Owns this execution's values; neither the command nor a cached plan can change them. +internal sealed class PreparedSelectStatement(PreparedSelectTemplate template, long[] values) : QueryStatement +{ + internal PreparedSelectTemplate Template { get; } = template; + internal IndexScanRange GetRange() => Template.GetRange(values); + internal SelectStatement BindLiterals() => Template.BindLiterals(values); +} diff --git a/src/CSharpDB.Execution/PrimaryKeyRangeScanOperator.cs b/src/CSharpDB.Execution/PrimaryKeyRangeScanOperator.cs new file mode 100644 index 00000000..411363d9 --- /dev/null +++ b/src/CSharpDB.Execution/PrimaryKeyRangeScanOperator.cs @@ -0,0 +1,220 @@ +using CSharpDB.Primitives; + +namespace CSharpDB.Execution; + +/// +/// Reads a bounded physical integer primary-key range, projecting directly from each table record. +/// The tree and cursor belong to one execution; callers must resolve the tree from the current pager. +/// +internal sealed class PrimaryKeyRangeScanOperator : + IOperator, + IBatchOperator, + IRowBufferReuseController, + IBatchBufferReuseController, + IPhysicalOperatorMetadataProvider +{ + private const int DefaultBatchSize = 64; + + private readonly BTree _tree; + private readonly TableSchema _schema; + private readonly IndexScanRange _range; + private readonly IRecordSerializer _serializer; + private readonly int[]? _projection; + private readonly bool _primaryKeyOnly; + private readonly int _decodedColumnCount; + private BTreeCursor? _cursor; + private bool _started; + private bool _exhausted; + private bool _reuseCurrentRowBuffer = true; + private bool _reuseCurrentBatch = true; + private DbValue[]? _rowBuffer; + private DbValue[]? _decodedRow; + private RowBatch? _currentBatch; + + public PrimaryKeyRangeScanOperator( + BTree tree, + TableSchema schema, + IndexScanRange range, + IRecordSerializer serializer, + int[]? projection, + ColumnDefinition[] outputColumns) + { + _tree = tree; + _schema = schema; + _range = range; + _serializer = serializer; + _projection = projection; + OutputSchema = outputColumns; + + if (projection is null) + { + if (outputColumns.Length != schema.Columns.Count) + throw new ArgumentException("Star output must match the table schema.", nameof(outputColumns)); + _decodedColumnCount = schema.Columns.Count; + return; + } + + if (outputColumns.Length != projection.Length) + throw new ArgumentException("Projection and output column counts must match.", nameof(outputColumns)); + int primaryKey = schema.PrimaryKeyColumnIndex; + _primaryKeyOnly = primaryKey >= 0; + int maxColumn = -1; + foreach (int column in projection) + { + if ((uint)column >= (uint)schema.Columns.Count) + throw new ArgumentOutOfRangeException(nameof(projection)); + _primaryKeyOnly &= column == primaryKey; + maxColumn = Math.Max(maxColumn, column); + } + _decodedColumnCount = maxColumn + 1; + } + + public ColumnDefinition[] OutputSchema { get; } + public bool ReusesCurrentRowBuffer => _reuseCurrentRowBuffer; + public DbValue[] Current { get; private set; } = Array.Empty(); + public long CurrentRowId { get; private set; } + public bool ReusesCurrentBatch => _reuseCurrentBatch; + public RowBatch CurrentBatch => _currentBatch ??= new RowBatch(OutputSchema.Length, 0); + + PhysicalOperatorMetadata IPhysicalOperatorMetadataProvider.GetPhysicalOperatorMetadata() + => new(AccessPath: PhysicalAccessPath.OrderedIndex, ObjectName: _schema.TableName); + + public async ValueTask OpenAsync(CancellationToken ct = default) + { + if (_cursor is not null) + await _cursor.DisposeAsync(); + + // This overload records the table-row read range; it does not enforce cursor bounds. + _cursor = _tree.CreateCursor(_range); + _started = false; + _exhausted = _range.LowerBound.HasValue && _range.UpperBound.HasValue && + (_range.LowerBound > _range.UpperBound || _range.LowerBound == _range.UpperBound && + (!_range.LowerInclusive || !_range.UpperInclusive)); + _rowBuffer = null; + _decodedRow = null; + _currentBatch = null; + Current = Array.Empty(); + } + + public async ValueTask MoveNextAsync(CancellationToken ct = default) + { + if (!await MoveToNextInRangeAsync(ct)) + { + Current = Array.Empty(); + return false; + } + + DbValue[] row = _reuseCurrentRowBuffer + ? _rowBuffer ??= new DbValue[OutputSchema.Length] + : new DbValue[OutputSchema.Length]; + ProjectCurrent(row); + CurrentRowId = _cursor!.CurrentKey; + Current = row; + return true; + } + + public async ValueTask MoveNextBatchAsync(CancellationToken ct = default) + { + if (!await MoveToNextInRangeAsync(ct)) + { + _currentBatch = null; + return false; + } + + // Allocate a full batch only after a caller requests one and a matching row exists. + RowBatch batch = _reuseCurrentBatch && _currentBatch is { Capacity: DefaultBatchSize } + ? _currentBatch + : new RowBatch(OutputSchema.Length, DefaultBatchSize); + batch.Reset(); + do + { + int rowIndex = batch.Count; + ProjectCurrent(batch.GetWritableRowSpan(rowIndex)); + batch.CommitWrittenRow(rowIndex); + } + while (batch.Count < batch.Capacity && await MoveToNextInRangeAsync(ct)); + + _currentBatch = batch; + return true; + } + + public void SetReuseCurrentRowBuffer(bool reuse) + { + if (_reuseCurrentRowBuffer == reuse) return; + _reuseCurrentRowBuffer = reuse; + _rowBuffer = null; + } + + public void SetReuseCurrentBatch(bool reuse) + { + if (_reuseCurrentBatch == reuse) return; + _reuseCurrentBatch = reuse; + // A batch handed out with reuse disabled remains owned by its consumer. + _currentBatch = null; + } + + public async ValueTask DisposeAsync() + { + if (_cursor is not null) + { + await _cursor.DisposeAsync(); + _cursor = null; + } + _exhausted = true; + _rowBuffer = null; + _decodedRow = null; + _currentBatch = null; + Current = Array.Empty(); + } + + private async ValueTask MoveToNextInRangeAsync(CancellationToken ct) + { + if (_cursor is null || _exhausted) return false; + + bool found; + if (!_started && _range.LowerBound.HasValue) + { + _started = true; + found = await _cursor.SeekAsync(_range.LowerBound.Value, ct); + if (found && !_range.LowerInclusive && _cursor.CurrentKey == _range.LowerBound.Value) + found = await _cursor.MoveNextAsync(ct); + } + else + { + _started = true; + found = await _cursor.MoveNextAsync(ct); + } + + if (!found || _range.UpperBound.HasValue && + (_range.UpperInclusive ? _cursor.CurrentKey > _range.UpperBound.Value : _cursor.CurrentKey >= _range.UpperBound.Value)) + { + _exhausted = true; + return false; + } + return true; + } + + private void ProjectCurrent(Span destination) + { + if (_primaryKeyOnly) + { + destination.Fill(DbValue.FromInteger(_cursor!.CurrentKey)); + return; + } + + ReadOnlySpan payload = _cursor!.CurrentValue.Span; + if (_projection is null) + { + int decoded = _serializer.DecodeInto(payload, destination); + if (decoded < destination.Length) destination[decoded..].Fill(DbValue.Null); + return; + } + + // Decode only through the highest requested ordinal, padding rows stored before ALTER ADD. + _decodedRow ??= new DbValue[_decodedColumnCount]; + int count = _serializer.DecodeInto(payload, _decodedRow); + if (count < _decodedRow.Length) _decodedRow.AsSpan(count).Fill(DbValue.Null); + for (int i = 0; i < _projection.Length; i++) + destination[i] = _decodedRow[_projection[i]]; + } +} diff --git a/src/CSharpDB.Execution/QueryPlanner.PhysicalPlans.cs b/src/CSharpDB.Execution/QueryPlanner.PhysicalPlans.cs index 5c008b8e..44955ae5 100644 --- a/src/CSharpDB.Execution/QueryPlanner.PhysicalPlans.cs +++ b/src/CSharpDB.Execution/QueryPlanner.PhysicalPlans.cs @@ -509,6 +509,7 @@ private async ValueTask CapturePlainMutationTargetAsync( ? TryBuildIndexScan(tableName, where, schema, out remainingWhere) ?? TryBuildIntegerIndexRangeScan(tableName, where, schema, out remainingWhere) ?? TryBuildOrderedTextIndexRangeScan(tableName, where, schema, out remainingWhere) + ?? TryBuildPrimaryKeyRangeScan(tableName, where, schema, out remainingWhere) ?? new TableScanOperator( tree, schema, diff --git a/src/CSharpDB.Execution/QueryPlanner.PreparedSelect.cs b/src/CSharpDB.Execution/QueryPlanner.PreparedSelect.cs new file mode 100644 index 00000000..eb9b6dd8 --- /dev/null +++ b/src/CSharpDB.Execution/QueryPlanner.PreparedSelect.cs @@ -0,0 +1,180 @@ +using CSharpDB.Primitives; +using CSharpDB.Sql; +using CSharpDB.Storage.Indexing; + +namespace CSharpDB.Execution; + +public sealed partial class QueryPlanner +{ + private const int MaxPreparedSelectPlans = 256; + // Reader planners resolve table roots through their frozen catalog view. + internal bool EnablePreparedPrimaryKeyRanges { get; init; } = true; + private Dictionary? _preparedSelectPlans; + private Dictionary PreparedSelectPlans => _preparedSelectPlans ??= new(); + private Queue? _preparedSelectPlanOrder; + private Queue PreparedSelectPlanOrder => _preparedSelectPlanOrder ??= new(); + + private QueryResult ExecutePreparedSelect(PreparedSelectStatement execution) + { + PreparedSelectTemplate template = execution.Template; + // Temporary tables may shadow a persistent table without changing its catalog schema version. + if (_cteData is not null || HasTemporaryTables || PhysicalPlanCapture.IsActive) + return ExecuteSelect(execution.BindLiterals()); + + bool hit = PreparedSelectPlans.TryGetValue(template, out PreparedIndexPlan? plan); + if (!hit) + { + plan = TryPrepareIndexPlan(template); + PreparedSelectPlans.Add(template, plan); + PreparedSelectPlanOrder.Enqueue(template); + if (PreparedSelectPlans.Count > MaxPreparedSelectPlans) + _preparedSelectPlans?.Remove(PreparedSelectPlanOrder.Dequeue()); + } + if (plan is null) return ExecuteSelect(execution.BindLiterals()); + + SelectStatement statement = template.Statement; + IndexScanRange range = execution.GetRange(); + bool empty = range.LowerBound.HasValue && range.UpperBound.HasValue && + (range.LowerBound > range.UpperBound || range.LowerBound == range.UpperBound && + (!range.LowerInclusive || !range.UpperInclusive)); + IOperator op; + bool equality = range.LowerBound.HasValue && range.LowerBound == range.UpperBound; + // ANALYZE can make an equality index lookup unattractive for a frequent value. + // Retain the existing value-sensitive choice instead of freezing it into this plan. + if (!empty && equality && plan.Index is { IsUnique: false } && + TryEstimateLookupRowCount(plan.Schema.TableName, plan.Schema.Columns[plan.KeyColumnIndex].Name, + DbValue.FromInteger(range.LowerBound!.Value), out long estimatedRows, out long tableRows) && + !ShouldPreferNonUniqueLookup(tableRows, estimatedRows, requiresResidualPredicate: false)) + return ExecuteSelect(execution.BindLiterals()); + + if (hit) _selectPlanCacheHitCount++; + else { _selectPlanCacheMissCount++; _selectPlanCacheStoreCount++; } + ObservePlanCacheLookup(hit); + if (!empty && equality && plan.Index is null) + return ObserveSelectedPlan(ExecutePreparedPrimaryKey(statement, plan, range.LowerBound!.Value), + SelectPlanKind.FastPrimaryKeyLookup, QueryPlanAccessPathCategory.PrimaryKeyLookup); + if (empty) + { + op = new MaterializedOperator([], plan.OutputColumns); + } + else if (plan.Index is null) + { + op = new PrimaryKeyRangeScanOperator(_catalog.GetTableTree(plan.Schema.TableName, _pager), + plan.Schema, range, GetReadSerializer(plan.Schema), plan.Projection, plan.OutputColumns); + op = ApplyOffsetAndLimit(op, statement.Offset, statement.Limit); + } + else + { + IOperator source = equality + ? BuildLookupOperator(plan.Schema.TableName, plan.Schema, false, plan.Index, range.LowerBound!.Value) + : new IndexOrderedScanOperator(_catalog.GetIndexStore(plan.Index!.IndexName, _pager), + _catalog.GetTableTree(plan.Schema.TableName, _pager), plan.Schema, plan.KeyColumnIndex, range, + recordSerializer: GetReadSerializer(plan.Schema)); + if (equality && CanUseSyncPointLookups && CanUseSyncIndexedLookupShortcut(statement) && + TryMaterializeSyncIndexedLookupResult(source, plan.Schema, GetReadSerializer(plan.Schema), + plan.OutputColumns, plan.Projection, out var syncResult)) + return ObserveSelectedPlan(syncResult, SelectPlanKind.FastIndexedLookup, QueryPlanAccessPathCategory.IndexSeek); + // Each call creates new operators with copied bounds; cached plans contain no cursor or values. + if (plan.Projection is null) op = source; + else if (source is IndexOrderedScanOperator ordered && + TryBuildCoveredOrderedIndexProjectionOperator(ordered, plan.Schema, plan.Projection, plan.OutputColumns, out var covered)) + op = covered; + else op = new ProjectionOperator(source, plan.Projection, plan.OutputColumns, plan.Schema); + op = ApplyOffsetAndLimit(op, statement.Offset, statement.Limit); + } + // Tiny windows do not amortize a batch buffer; offsets may still require consuming many rows. + QueryResult result = statement.Limit is >= 0 and <= LimitOperator.SmallLimitRowModeThreshold && + statement.Offset.GetValueOrDefault() == 0 + ? new QueryResult(op) : CreateQueryResult(op); + return ObserveSelectedPlan(result, + plan.Index is null ? (equality ? SelectPlanKind.FastPrimaryKeyLookup : SelectPlanKind.General) : SelectPlanKind.FastIndexedLookup, + plan.Index is null ? (equality ? QueryPlanAccessPathCategory.PrimaryKeyLookup : QueryPlanAccessPathCategory.IndexScan) : + equality ? QueryPlanAccessPathCategory.IndexSeek : QueryPlanAccessPathCategory.IndexScan); + } + + private QueryResult ExecutePreparedPrimaryKey(SelectStatement statement, PreparedIndexPlan plan, long key) + { + var tableTree = _catalog.GetTableTree(plan.Schema.TableName, _pager); + // Preserve the ordinary PK path's cache-only star and decode-free key projections. + if (CanUseSyncPointLookups && !statement.Offset.HasValue && !statement.Limit.HasValue && + (plan.Projection is null || plan.PrimaryKeyOnlyProjection) && + tableTree.TryFindCachedMemory(key, out var payload)) + { + DbValue[]? row = null; + if (payload is { } memory) + { + if (plan.Projection is null) + row = DecodeRowWithNullPadding(GetReadSerializer(plan.Schema), memory.Span, plan.OutputColumns.Length); + else + { + row = new DbValue[plan.OutputColumns.Length]; + Array.Fill(row, DbValue.FromInteger(key)); + } + } + return QueryResult.FromSyncLookup(row, plan.OutputColumns); + } + + IOperator op; + if (plan.PrimaryKeyOnlyProjection) + op = new PrimaryKeyProjectionLookupOperator(tableTree, key, plan.OutputColumns); + else + { + var lookup = new PrimaryKeyLookupOperator(tableTree, plan.Schema, key, GetReadSerializer(plan.Schema)); + if (plan.Projection is null) op = lookup; + else + { + lookup.SetDecodedColumnUpperBound(plan.MaxProjectedColumn); + op = new ProjectionOperator(lookup, plan.Projection, plan.OutputColumns, plan.Schema); + } + } + // A physical PK equality yields at most one row; avoid allocating a projection batch. + return new QueryResult(ApplyOffsetAndLimit(op, statement.Offset, statement.Limit)); + } + + private PreparedIndexPlan? TryPrepareIndexPlan(PreparedSelectTemplate template) + { + var statement = template.Statement; + var table = (SimpleTableRef)statement.From; + if (_catalog.IsView(table.TableName) || IsSystemCatalogTable(table.TableName) || IsExternalTable(table.TableName)) return null; + var baseSchema = _catalog.GetTable(table.TableName); + if (baseSchema is null) return null; + var schema = CreateSimpleTableQuerySchema(baseSchema, table.Alias); + int Resolve(ColumnRefExpression column) => column.TableAlias is null + ? schema.GetColumnIndex(column.ColumnName) : schema.GetQualifiedColumnIndex(column.TableAlias, column.ColumnName); + int key = Resolve(template.KeyColumn); + if (key < 0 || key >= schema.Columns.Count || schema.Columns[key].Type != DbType.Integer || + schema.Columns[key].EffectiveType.Kind == SqlTypeKind.Boolean) return null; + if (statement.OrderBy is { Count: 1 } && Resolve((ColumnRefExpression)statement.OrderBy[0].Expression) != key) return null; + bool primaryKey = (template.HasEquality || EnablePreparedPrimaryKeyRanges) && schema.PrimaryKeyColumnIndex == key && + ResolvePrimaryKeyDefinition(baseSchema) is { } keyDefinition && + UsesPhysicalIntegerPrimaryKey(baseSchema, keyDefinition); + IndexSchema? selected = null; + if (!primaryKey) + { + foreach (var index in _catalog.GetSqlIndexesForTable(table.TableName)) + { + if (!UsesDirectIntegerIndexKey(index, schema) || schema.GetColumnIndex(index.Columns[0]) != key) continue; + if (selected is null || index.IsUnique) selected = index; + if (index.IsUnique) break; + } + if (selected is null) return null; + } + int[]? projection = null; + ColumnDefinition[] output; + if (statement.Columns.Count == 1 && statement.Columns[0].IsStar) output = GetSchemaColumnsArray(schema); + else if (!TryBuildColumnProjection(statement.Columns, schema, out projection, out output)) return null; + return new PreparedIndexPlan(schema, selected, key, projection, output, + primaryKey && projection is not null && IsPrimaryKeyOnlyProjection(projection, key), + projection is { Length: > 0 } ? projection.Max() : -1); + } + + private void ClearPreparedSelectPlans() + { + _preparedSelectPlans?.Clear(); + _preparedSelectPlanOrder?.Clear(); + } + + // A null secondary index denotes access through the physical integer primary key. + private sealed record PreparedIndexPlan(TableSchema Schema, IndexSchema? Index, int KeyColumnIndex, + int[]? Projection, ColumnDefinition[] OutputColumns, bool PrimaryKeyOnlyProjection, int MaxProjectedColumn); +} diff --git a/src/CSharpDB.Execution/QueryPlanner.cs b/src/CSharpDB.Execution/QueryPlanner.cs index c78ab7c9..6b41dcba 100644 --- a/src/CSharpDB.Execution/QueryPlanner.cs +++ b/src/CSharpDB.Execution/QueryPlanner.cs @@ -681,21 +681,37 @@ private sealed record PendingForeignKeyUpdate( private const int MaxTriggerDepth = 16; private const int MaxForeignKeyCascadeDepth = 64; + // Short-lived reader planners allocate only the caches their queries use. + // Cleanup accesses the backing fields so it never creates an unused cache. /// Cache of parsed trigger bodies to avoid re-parsing on every row. - private readonly Dictionary> _triggerBodyCache = new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary _nextRowIdCache = new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary _rowIdReservationLeases = new(StringComparer.OrdinalIgnoreCase); - private readonly HashSet _dirtyNextRowIdTables = new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary> _compiledExpressionCache = new(); - private readonly Dictionary _compiledSpanExpressionCache = new(); - private readonly Dictionary _qualifiedMappingFingerprintCache = new(ReferenceEqualityComparer.Instance); - private readonly Dictionary> _logicalMutationConflictMetadataCache = new(ReferenceEqualityComparer.Instance); - private readonly Dictionary _tableSchemaArrayCache = new(ReferenceEqualityComparer.Instance); - private readonly Dictionary<(TableSchema Schema, int ColumnIndex), ColumnDefinition[]> _singleColumnOutputSchemaCache = new(); - private readonly Dictionary _resolvedInsertIndexPlanCache = new(ReferenceEqualityComparer.Instance); - private readonly Dictionary _requiresQualifiedMappingCache = new(ReferenceEqualityComparer.Instance); - private readonly Dictionary _correlationTableRefSchemaCache = new(ReferenceEqualityComparer.Instance); - private readonly Dictionary _correlationQueryOutputSchemaCache = new(ReferenceEqualityComparer.Instance); + private Dictionary>? _triggerBodyCache; + private Dictionary> TriggerBodyCache => _triggerBodyCache ??= new(StringComparer.OrdinalIgnoreCase); + private Dictionary? _nextRowIdCache; + private Dictionary NextRowIdCache => _nextRowIdCache ??= new(StringComparer.OrdinalIgnoreCase); + private Dictionary? _rowIdReservationLeases; + private Dictionary RowIdReservationLeases => _rowIdReservationLeases ??= new(StringComparer.OrdinalIgnoreCase); + private HashSet? _dirtyNextRowIdTables; + private HashSet DirtyNextRowIdTables => _dirtyNextRowIdTables ??= new(StringComparer.OrdinalIgnoreCase); + private Dictionary>? _compiledExpressionCache; + private Dictionary> CompiledExpressionCache => _compiledExpressionCache ??= new(); + private Dictionary? _compiledSpanExpressionCache; + private Dictionary CompiledSpanExpressionCache => _compiledSpanExpressionCache ??= new(); + private Dictionary? _qualifiedMappingFingerprintCache; + private Dictionary QualifiedMappingFingerprintCache => _qualifiedMappingFingerprintCache ??= new(ReferenceEqualityComparer.Instance); + private Dictionary>? _logicalMutationConflictMetadataCache; + private Dictionary> LogicalMutationConflictMetadataCache => _logicalMutationConflictMetadataCache ??= new(ReferenceEqualityComparer.Instance); + private Dictionary? _tableSchemaArrayCache; + private Dictionary TableSchemaArrayCache => _tableSchemaArrayCache ??= new(ReferenceEqualityComparer.Instance); + private Dictionary<(TableSchema Schema, int ColumnIndex), ColumnDefinition[]>? _singleColumnOutputSchemaCache; + private Dictionary<(TableSchema Schema, int ColumnIndex), ColumnDefinition[]> SingleColumnOutputSchemaCache => _singleColumnOutputSchemaCache ??= new(); + private Dictionary? _resolvedInsertIndexPlanCache; + private Dictionary ResolvedInsertIndexPlanCache => _resolvedInsertIndexPlanCache ??= new(ReferenceEqualityComparer.Instance); + private Dictionary? _requiresQualifiedMappingCache; + private Dictionary RequiresQualifiedMappingCache => _requiresQualifiedMappingCache ??= new(ReferenceEqualityComparer.Instance); + private Dictionary? _correlationTableRefSchemaCache; + private Dictionary CorrelationTableRefSchemaCache => _correlationTableRefSchemaCache ??= new(ReferenceEqualityComparer.Instance); + private Dictionary? _correlationQueryOutputSchemaCache; + private Dictionary CorrelationQueryOutputSchemaCache => _correlationQueryOutputSchemaCache ??= new(ReferenceEqualityComparer.Instance); private List? _systemTablesRowsCache; private List? _systemInternalTablesRowsCache; private List? _systemColumnsRowsCache; @@ -711,11 +727,16 @@ private sealed record PendingForeignKeyUpdate( private long? _systemColumnsCountCache; private long? _systemIndexesCountCache; private long? _systemForeignKeysCountCache; - private readonly Dictionary _systemCatalogSchemaCache = new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary _selectPlanCache = new(ReferenceEqualityComparer.Instance); - private readonly Dictionary _selectBatchPlanCache = new(ReferenceEqualityComparer.Instance); - private readonly Dictionary _selectJoinReorderCache = new(ReferenceEqualityComparer.Instance); - private readonly Queue _selectPlanInsertionOrder = new(); + private Dictionary? _systemCatalogSchemaCache; + private Dictionary SystemCatalogSchemaCache => _systemCatalogSchemaCache ??= new(StringComparer.OrdinalIgnoreCase); + private Dictionary? _selectPlanCache; + private Dictionary SelectPlanCache => _selectPlanCache ??= new(ReferenceEqualityComparer.Instance); + private Dictionary? _selectBatchPlanCache; + private Dictionary SelectBatchPlanCache => _selectBatchPlanCache ??= new(ReferenceEqualityComparer.Instance); + private Dictionary? _selectJoinReorderCache; + private Dictionary SelectJoinReorderCache => _selectJoinReorderCache ??= new(ReferenceEqualityComparer.Instance); + private Queue? _selectPlanInsertionOrder; + private Queue SelectPlanInsertionOrder => _selectPlanInsertionOrder ??= new(); private long _selectPlanCacheHitCount; private long _selectPlanCacheMissCount; private long _selectPlanCacheReclassificationCount; @@ -740,7 +761,18 @@ private sealed record PendingForeignKeyUpdate( private readonly Action? _nextRowIdObservationProvider; private readonly Func? _rowVersionAllocator; private readonly bool _useTransientNextRowIdHints; - private readonly AdaptiveQueryReoptimizationRuntimeDiagnostics _adaptiveRuntimeDiagnostics; + private AdaptiveQueryReoptimizationRuntimeDiagnostics? _adaptiveRuntimeDiagnostics; + private IQueryPlanRuntimeObserver? _planRuntimeObserver; + private AdaptiveQueryReoptimizationRuntimeDiagnostics AdaptiveRuntimeDiagnostics + => _adaptiveRuntimeDiagnostics ??= new AdaptiveQueryReoptimizationRuntimeDiagnostics( + RecordAdaptiveAttempt, + RecordAdaptiveSuccessfulSwitch, + RecordAdaptiveRejectedSwitch, + RecordAdaptiveDivergence, + RecordAdaptiveBufferedRows) + { + RuntimeObserver = PlanRuntimeObserver, + }; private const int MaxCompiledExpressionCacheEntries = 4096; private const int MaxSelectPlanCacheEntries = 1024; @@ -893,8 +925,13 @@ internal readonly record struct SelectPlanCacheDiagnostics( /// internal IQueryPlanRuntimeObserver? PlanRuntimeObserver { - get => _adaptiveRuntimeDiagnostics.RuntimeObserver; - set => _adaptiveRuntimeDiagnostics.RuntimeObserver = value; + get => Volatile.Read(ref _planRuntimeObserver); + set + { + Volatile.Write(ref _planRuntimeObserver, value); + if (_adaptiveRuntimeDiagnostics is { } diagnostics) + diagnostics.RuntimeObserver = value; + } } public QueryPlanner( @@ -967,26 +1004,21 @@ internal QueryPlanner( _useTransientNextRowIdHints = useTransientNextRowIdHints; AdaptiveQueryReoptimization = NormalizeAdaptiveQueryReoptimizationOptions(adaptiveQueryReoptimization); WindowExecution = NormalizeWindowExecutionOptions(windowExecution); - _adaptiveRuntimeDiagnostics = new AdaptiveQueryReoptimizationRuntimeDiagnostics( - RecordAdaptiveAttempt, - RecordAdaptiveSuccessfulSwitch, - RecordAdaptiveRejectedSwitch, - RecordAdaptiveDivergence, - RecordAdaptiveBufferedRows); } public IReadOnlyCollection> GetCommittedNextRowIdHints() { - if (_dirtyNextRowIdTables.Count == 0) + if (_dirtyNextRowIdTables is not { Count: > 0 } dirtyTables) return Array.Empty>(); - var committed = new List>(_dirtyNextRowIdTables.Count); - foreach (string tableName in _dirtyNextRowIdTables) + var committed = new List>(dirtyTables.Count); + foreach (string tableName in dirtyTables) { if (IsTemporaryTable(tableName)) continue; - if (_nextRowIdCache.TryGetValue(tableName, out long nextRowId) && nextRowId > 0) + if (_nextRowIdCache is { } nextRowIds && + nextRowIds.TryGetValue(tableName, out long nextRowId) && nextRowId > 0) committed.Add(new KeyValuePair(tableName, nextRowId)); } @@ -1195,6 +1227,9 @@ private ValueTask ExecuteQueryAsync( CancellationToken ct, bool suppressAdaptiveReoptimization = false) { + if (stmt is PreparedSelectStatement prepared) + return ValueTask.FromResult(ExecutePreparedSelect(prepared)); + WindowExpressionSupport.ValidateQuery(stmt); ValidateStarAggregateProjection(stmt); @@ -1267,7 +1302,7 @@ internal SelectPlanCacheDiagnostics GetSelectPlanCacheDiagnostics() _selectPlanCacheMissCount, _selectPlanCacheReclassificationCount, _selectPlanCacheStoreCount, - _selectPlanCache.Count); + (_selectPlanCache?.Count ?? 0) + (_preparedSelectPlans?.Count ?? 0)); internal void ResetSelectPlanCacheDiagnostics() { @@ -1404,17 +1439,17 @@ private void InvalidateSchemaSensitiveCachesIfNeeded() if (currentVersion == _observedSchemaVersion) return; - _triggerBodyCache.Clear(); - _nextRowIdCache.Clear(); - _rowIdReservationLeases.Clear(); - _dirtyNextRowIdTables.Clear(); - _compiledExpressionCache.Clear(); - _compiledSpanExpressionCache.Clear(); - _qualifiedMappingFingerprintCache.Clear(); - _tableSchemaArrayCache.Clear(); - _singleColumnOutputSchemaCache.Clear(); - _resolvedInsertIndexPlanCache.Clear(); - _requiresQualifiedMappingCache.Clear(); + _triggerBodyCache?.Clear(); + _nextRowIdCache?.Clear(); + _rowIdReservationLeases?.Clear(); + _dirtyNextRowIdTables?.Clear(); + _compiledExpressionCache?.Clear(); + _compiledSpanExpressionCache?.Clear(); + _qualifiedMappingFingerprintCache?.Clear(); + _tableSchemaArrayCache?.Clear(); + _singleColumnOutputSchemaCache?.Clear(); + _resolvedInsertIndexPlanCache?.Clear(); + _requiresQualifiedMappingCache?.Clear(); _systemTablesRowsCache = null; _systemInternalTablesRowsCache = null; _systemColumnsRowsCache = null; @@ -1430,10 +1465,11 @@ private void InvalidateSchemaSensitiveCachesIfNeeded() _systemColumnsCountCache = null; _systemIndexesCountCache = null; _systemForeignKeysCountCache = null; - _selectPlanCache.Clear(); - _selectBatchPlanCache.Clear(); - _selectJoinReorderCache.Clear(); - _selectPlanInsertionOrder.Clear(); + _selectPlanCache?.Clear(); + ClearPreparedSelectPlans(); + _selectBatchPlanCache?.Clear(); + _selectJoinReorderCache?.Clear(); + _selectPlanInsertionOrder?.Clear(); _observedSchemaVersion = currentVersion; } @@ -1537,9 +1573,9 @@ private async ValueTask ExecuteCreateTableAsync(CreateTableStatemen await CreateKeyConstraintBackingIndexAsync(schema, keyConstraints[i], ct); for (int i = 0; i < foreignKeys.Length; i++) await CreateForeignKeySupportIndexAsync(schema, foreignKeys[i], ct); - _nextRowIdCache.Remove(stmt.TableName); - _rowIdReservationLeases.Remove(stmt.TableName); - _dirtyNextRowIdTables.Remove(stmt.TableName); + _nextRowIdCache?.Remove(stmt.TableName); + _rowIdReservationLeases?.Remove(stmt.TableName); + _dirtyNextRowIdTables?.Remove(stmt.TableName); return new QueryResult(0); } @@ -1590,9 +1626,9 @@ private async ValueTask ExecuteCreateTemporaryTableAsync(CreateTabl RowConstraintValidator.ValidateSchemaDefinitions(schema); await temporaryTables.CreateTableAsync(schema, ct); - _nextRowIdCache.Remove(stmt.TableName); - _rowIdReservationLeases.Remove(stmt.TableName); - _dirtyNextRowIdTables.Remove(stmt.TableName); + _nextRowIdCache?.Remove(stmt.TableName); + _rowIdReservationLeases?.Remove(stmt.TableName); + _dirtyNextRowIdTables?.Remove(stmt.TableName); InvalidateTempSystemCatalogCaches(); return new QueryResult(0); } @@ -1694,9 +1730,9 @@ private async ValueTask ExecuteDropTableAsync(DropTableStatement st } await _catalog.DropTableAsync(stmt.TableName, ct); - _nextRowIdCache.Remove(stmt.TableName); - _rowIdReservationLeases.Remove(stmt.TableName); - _dirtyNextRowIdTables.Remove(stmt.TableName); + _nextRowIdCache?.Remove(stmt.TableName); + _rowIdReservationLeases?.Remove(stmt.TableName); + _dirtyNextRowIdTables?.Remove(stmt.TableName); return new QueryResult(0); } @@ -1712,9 +1748,9 @@ private async ValueTask ExecuteDropTemporaryTableAsync(DropTableSta } await temporaryTables.DropTableAsync(stmt.TableName, ct); - _nextRowIdCache.Remove(stmt.TableName); - _rowIdReservationLeases.Remove(stmt.TableName); - _dirtyNextRowIdTables.Remove(stmt.TableName); + _nextRowIdCache?.Remove(stmt.TableName); + _rowIdReservationLeases?.Remove(stmt.TableName); + _dirtyNextRowIdTables?.Remove(stmt.TableName); InvalidateTempSystemCatalogCaches(); return new QueryResult(0); } @@ -2376,12 +2412,14 @@ await EnsureNoRenameTableTriggerOrValidationRuleAsync( ct); await RenameTableWithDependenciesAsync(stmt.TableName, rename.NewTableName, schema, ct); - if (_nextRowIdCache.Remove(stmt.TableName, out long nextRowId)) - _nextRowIdCache[rename.NewTableName] = nextRowId; - if (_rowIdReservationLeases.Remove(stmt.TableName, out RowIdReservationLease? rowIdLease)) - _rowIdReservationLeases[rename.NewTableName] = rowIdLease; - if (_dirtyNextRowIdTables.Remove(stmt.TableName)) - _dirtyNextRowIdTables.Add(rename.NewTableName); + if (_nextRowIdCache is { } nextRowIds && + nextRowIds.Remove(stmt.TableName, out long nextRowId)) + NextRowIdCache[rename.NewTableName] = nextRowId; + if (_rowIdReservationLeases is { } rowIdLeases && + rowIdLeases.Remove(stmt.TableName, out RowIdReservationLease? rowIdLease)) + RowIdReservationLeases[rename.NewTableName] = rowIdLease; + if (_dirtyNextRowIdTables?.Remove(stmt.TableName) == true) + DirtyNextRowIdTables.Add(rename.NewTableName); break; } @@ -2393,7 +2431,7 @@ await EnsureNoRenameTableTriggerOrValidationRuleAsync( { currentNextRowId = sharedNextRowId; } - if (_nextRowIdCache.TryGetValue(stmt.TableName, out long cachedNextRowId) && + if (NextRowIdCache.TryGetValue(stmt.TableName, out long cachedNextRowId) && cachedNextRowId > currentNextRowId) { currentNextRowId = cachedNextRowId; @@ -2417,13 +2455,14 @@ await EnsureNoRenameTableTriggerOrValidationRuleAsync( NextRowId = reseed.NextRowId, }; await _catalog.UpdateTableSchemaAsync(stmt.TableName, reseededSchema, ct); - _nextRowIdCache[stmt.TableName] = reseed.NextRowId; - _dirtyNextRowIdTables.Add(stmt.TableName); + NextRowIdCache[stmt.TableName] = reseed.NextRowId; + DirtyNextRowIdTables.Add(stmt.TableName); _nextRowIdObservationProvider?.Invoke(stmt.TableName, reseed.NextRowId); - if (_rowIdReservationLeases.TryGetValue(stmt.TableName, out RowIdReservationLease? lease) && + if (_rowIdReservationLeases is { } rowIdLeases && + rowIdLeases.TryGetValue(stmt.TableName, out RowIdReservationLease? lease) && !lease.AdvanceTo(reseed.NextRowId)) { - _rowIdReservationLeases.Remove(stmt.TableName); + _rowIdReservationLeases?.Remove(stmt.TableName); } break; } @@ -3775,7 +3814,7 @@ private Expression BindOuterScopesInExpression( private TableSchema ResolveCorrelationTableRefSchema(TableRef tableRef) { - if (_correlationTableRefSchemaCache.TryGetValue(tableRef, out var cached)) + if (CorrelationTableRefSchemaCache.TryGetValue(tableRef, out var cached)) return cached; TableSchema resolved; @@ -3796,7 +3835,7 @@ private TableSchema ResolveCorrelationTableRefSchema(TableRef tableRef) throw new CSharpDbException(ErrorCode.Unknown, $"Unknown table ref type: {tableRef.GetType().Name}"); } - _correlationTableRefSchemaCache[tableRef] = resolved; + CorrelationTableRefSchemaCache[tableRef] = resolved; return resolved; } @@ -3828,7 +3867,7 @@ private ColumnDefinition[] ResolveCorrelationQueryOutputSchema(QueryStatement qu { ValidateStarAggregateProjection(query); - if (_correlationQueryOutputSchemaCache.TryGetValue(query, out var cached)) + if (CorrelationQueryOutputSchemaCache.TryGetValue(query, out var cached)) return cached; ColumnDefinition[] output; @@ -3876,7 +3915,7 @@ private ColumnDefinition[] ResolveCorrelationQueryOutputSchema(QueryStatement qu throw new CSharpDbException(ErrorCode.Unknown, $"Unknown query type: {query.GetType().Name}"); } - _correlationQueryOutputSchemaCache[query] = output; + CorrelationQueryOutputSchemaCache[query] = output; return output; } @@ -4062,7 +4101,7 @@ private async ValueTask ExecuteCreateTriggerAsync(CreateTriggerStat }; await _catalog.CreateTriggerAsync(schema, ct); - _triggerBodyCache.Remove(stmt.TriggerName); // invalidate cache + _triggerBodyCache?.Remove(stmt.TriggerName); // invalidate cache return new QueryResult(0); } @@ -4072,7 +4111,7 @@ private async ValueTask ExecuteDropTriggerAsync(DropTriggerStatemen return new QueryResult(0); await _catalog.DropTriggerAsync(stmt.TriggerName, ct); - _triggerBodyCache.Remove(stmt.TriggerName); + _triggerBodyCache?.Remove(stmt.TriggerName); return new QueryResult(0); } @@ -5468,10 +5507,10 @@ private async ValueTask ExecuteTriggerBodyAsync( try { // Parse the trigger body (cache it) - if (!_triggerBodyCache.TryGetValue(trigger.TriggerName, out var bodyStatements)) + if (!TriggerBodyCache.TryGetValue(trigger.TriggerName, out var bodyStatements)) { bodyStatements = ParseTriggerBody(trigger.BodySql); - _triggerBodyCache[trigger.TriggerName] = bodyStatements; + TriggerBodyCache[trigger.TriggerName] = bodyStatements; } // Build a composite schema that can resolve NEW.col and OLD.col @@ -5837,6 +5876,9 @@ internal async ValueTask ExecuteInsertAsync( bool persistRootChanges, CancellationToken ct = default) { + // Fast insert callers can bypass ExecuteAsync after another planner executes DDL. + InvalidateSchemaSensitiveCachesIfNeeded(); + if (HasTemporaryTable(stmt.TableName)) return await ExecuteTemporaryInsertAsync(stmt, ct); @@ -6852,7 +6894,7 @@ private QueryResult ExecuteSelect( QueryPlanAccessPathCategory.Temporary, explicitRuntimeObserver); - if (_selectPlanCache.TryGetValue(stmt, out var cachedPlan)) + if (SelectPlanCache.TryGetValue(stmt, out var cachedPlan)) { _selectPlanCacheHitCount++; ObservePlanCacheLookup( @@ -7318,22 +7360,22 @@ private static bool ContainsEqualityPredicate(Expression expression) private void CacheSelectPlan(SelectStatement stmt, SelectPlanKind kind) { _selectPlanCacheStoreCount++; - if (_selectPlanCache.TryAdd(stmt, kind)) + if (SelectPlanCache.TryAdd(stmt, kind)) { - _selectPlanInsertionOrder.Enqueue(stmt); + SelectPlanInsertionOrder.Enqueue(stmt); } else { - _selectPlanCache[stmt] = kind; + SelectPlanCache[stmt] = kind; } - while (_selectPlanCache.Count > MaxSelectPlanCacheEntries && - _selectPlanInsertionOrder.Count > 0) + while (SelectPlanCache.Count > MaxSelectPlanCacheEntries && + SelectPlanInsertionOrder.Count > 0) { - var evicted = _selectPlanInsertionOrder.Dequeue(); - _selectPlanCache.Remove(evicted); - _selectBatchPlanCache.Remove(evicted); - _selectJoinReorderCache.Remove(evicted); + var evicted = SelectPlanInsertionOrder.Dequeue(); + _selectPlanCache?.Remove(evicted); + _selectBatchPlanCache?.Remove(evicted); + _selectJoinReorderCache?.Remove(evicted); } } @@ -7359,10 +7401,10 @@ private TableRef GetOrCreateCachedJoinReorder(SelectStatement stmt, bool preserv return stmt.From; } - if (!_selectJoinReorderCache.TryGetValue(stmt, out var cached)) + if (!SelectJoinReorderCache.TryGetValue(stmt, out var cached)) { cached = new CachedSelectJoinReorder(); - _selectJoinReorderCache[stmt] = cached; + SelectJoinReorderCache[stmt] = cached; } if (!cached.Initialized) @@ -7422,6 +7464,7 @@ private QueryResult ExecuteSelectGeneral(SelectStatement stmt, bool suppressAdap if (stmt.Where != null) { var indexOp = TryBuildIndexScan(simpleRef.TableName, stmt.Where, schema, out remainingWhere); + indexOp ??= TryBuildPrimaryKeyRangeScan(simpleRef.TableName, stmt.Where, schema, out remainingWhere); if (indexOp != null) op = indexOp; } @@ -7437,6 +7480,16 @@ private QueryResult ExecuteSelectGeneral(SelectStatement stmt, bool suppressAdap } } + // A bounded source still needs its residual predicate before ordering/TopN. + // It does not support the table scan's payload-level predicate pushdown. + if (remainingWhere != null && PhysicalPlanCapture.Unwrap(op) is PrimaryKeyRangeScanOperator) + { + op = AnnotatePhysicalPredicate( + new FilterOperator(op, GetOrCompileSpanExpression(remainingWhere, schema)), + remainingWhere); + remainingWhere = null; + } + // Push simple comparison predicates down to payload-level filtering so // non-matching rows can skip full row decode. if (remainingWhere != null && TryPushDownSimplePreDecodeFilter(op, remainingWhere, schema, out var pushedWhere)) @@ -9115,6 +9168,9 @@ internal async ValueTask ExecuteSimpleInsertAsync( bool persistRootChanges, CancellationToken ct = default) { + // Fast insert callers can bypass ExecuteAsync after another planner executes DDL. + InvalidateSchemaSensitiveCachesIfNeeded(); + if (HasTemporaryTable(insert.TableName)) return await ExecuteTemporarySimpleInsertAsync(insert, ct); @@ -9254,6 +9310,8 @@ private async ValueTask ExecuteBareSimpleInsertAsync( bool persistRootChanges, CancellationToken ct) { + int primaryKeyColumnIndex = schema.PrimaryKeyColumnIndex; + int rowVersionColumnIndex = GetRowVersionColumnIndex(schema); int inserted = 0; long? generatedIntegerKey = null; byte[]? generatedRowVersion = null; @@ -9273,9 +9331,9 @@ private async ValueTask ExecuteBareSimpleInsertAsync( { for (int i = 0; i < insert.RowCount; i++) { - DbValue[] row = ResolveSimpleInsertRow(schema, explicitColumnIndices, insert.ValueRows[i]); + DbValue[] row = ResolveSimpleInsertRow(schema, explicitColumnIndices, insert.ValueRows[i], rowVersionColumnIndex); - InsertRowResult insertRow = await ExecuteBareInsertRowAsync( + InsertRowResult insertRow = await ExecuteBareInsertRowCoreAsync( insert.TableName, schema, tree, @@ -9284,6 +9342,8 @@ private async ValueTask ExecuteBareSimpleInsertAsync( insertTraversalSet, reusableEncodingBuffer, rowIdReservationCountHint: Math.Max(1, insert.RowCount - inserted), + primaryKeyColumnIndex, + rowVersionColumnIndex, ct); if (insert.RowCount == 1) { @@ -9332,7 +9392,22 @@ await PersistForeignKeyMutationContextAsync( ct); } - private async ValueTask ExecuteBareInsertRowAsync( + private ValueTask ExecuteBareInsertRowAsync( + string tableName, + TableSchema schema, + BTree tree, + DbValue[] row, + List? traversalPath, + HashSet? traversalSet, + ReusableInsertEncodingBuffer? reusableEncodingBuffer, + int rowIdReservationCountHint, + CancellationToken ct) + => ExecuteBareInsertRowCoreAsync( + tableName, schema, tree, row, traversalPath, traversalSet, + reusableEncodingBuffer, rowIdReservationCountHint, + schema.PrimaryKeyColumnIndex, GetRowVersionColumnIndex(schema), ct); + + private async ValueTask ExecuteBareInsertRowCoreAsync( string tableName, TableSchema schema, BTree tree, @@ -9341,6 +9416,8 @@ private async ValueTask ExecuteBareInsertRowAsync( HashSet? traversalSet, ReusableInsertEncodingBuffer? reusableEncodingBuffer, int rowIdReservationCountHint, + int primaryKeyColumnIndex, + int rowVersionColumnIndex, CancellationToken ct) { var (rowId, autoGeneratedRowId) = await ResolveRowIdForInsertAsync( @@ -9349,13 +9426,14 @@ private async ValueTask ExecuteBareInsertRowAsync( tree, row, rowIdReservationCountHint, + primaryKeyColumnIndex, ct); - byte[]? generatedRowVersion = await MaterializeInitialRowVersionAsync(schema, row, ct); + byte[]? generatedRowVersion = await MaterializeInitialRowVersionAsync(rowVersionColumnIndex, row, ct); RowConstraintValidator.ValidateRow(schema, row); long? generatedIntegerIdentity = autoGeneratedRowId && - schema.PrimaryKeyColumnIndex >= 0 && - schema.Columns[schema.PrimaryKeyColumnIndex].IsIdentity && - schema.Columns[schema.PrimaryKeyColumnIndex].Type == DbType.Integer + primaryKeyColumnIndex >= 0 && + schema.Columns[primaryKeyColumnIndex].IsIdentity && + schema.Columns[primaryKeyColumnIndex].Type == DbType.Integer ? rowId : null; while (true) @@ -9379,12 +9457,13 @@ private async ValueTask ExecuteBareInsertRowAsync( tree, row, rowIdReservationCountHint, + primaryKeyColumnIndex, ct); RowConstraintValidator.ValidateRow(schema, row); generatedIntegerIdentity = autoGeneratedRowId && - schema.PrimaryKeyColumnIndex >= 0 && - schema.Columns[schema.PrimaryKeyColumnIndex].IsIdentity && - schema.Columns[schema.PrimaryKeyColumnIndex].Type == DbType.Integer + primaryKeyColumnIndex >= 0 && + schema.Columns[primaryKeyColumnIndex].IsIdentity && + schema.Columns[primaryKeyColumnIndex].Type == DbType.Integer ? rowId : null; } @@ -11474,7 +11553,8 @@ await GetSinglePersistedGeneratedRowVersionAsync( { indexedSource = TryBuildIndexScan(tableName, where, schema, out remainingWhere) ?? TryBuildIntegerIndexRangeScan(tableName, where, schema, out remainingWhere) - ?? TryBuildOrderedTextIndexRangeScan(tableName, where, schema, out remainingWhere); + ?? TryBuildOrderedTextIndexRangeScan(tableName, where, schema, out remainingWhere) + ?? TryBuildPrimaryKeyRangeScan(tableName, where, schema, out remainingWhere); } if (indexedSource != null) @@ -11569,6 +11649,9 @@ private static bool TryGetCurrentRowId(IOperator source, out long rowId) case PrimaryKeyLookupOperator primaryKeyLookup: rowId = primaryKeyLookup.CurrentRowId; return true; + case PrimaryKeyRangeScanOperator primaryKeyRange: + rowId = primaryKeyRange.CurrentRowId; + return true; case IndexScanOperator indexScan: rowId = indexScan.CurrentRowId; return true; @@ -12932,6 +13015,8 @@ viewFrom is JoinTableRef viewJoin && { Expression? remainingLocalPredicate = localPredicate; var indexedLocalOp = TryBuildIndexScan(simple.TableName, localPredicate, qualifiedTableSchema, out var localResidualPredicate); + indexedLocalOp ??= TryBuildPrimaryKeyRangeScan( + simple.TableName, localPredicate, qualifiedTableSchema, out localResidualPredicate); if (indexedLocalOp != null) { op = indexedLocalOp; @@ -14989,7 +15074,7 @@ private bool TryBuildIndexNestedLoopJoinOperator( createLookupJoin, createHashJoin, adaptiveLease, - _adaptiveRuntimeDiagnostics, + AdaptiveRuntimeDiagnostics, outerRows, estimatedOutputRowCount); } @@ -15491,7 +15576,7 @@ private bool TryBuildHashJoinOperator( estimatedOutputRowCount, _functions, adaptiveLease, - _adaptiveRuntimeDiagnostics); + AdaptiveRuntimeDiagnostics); } else { @@ -15864,6 +15949,42 @@ private static int ResolveJoinColumnIndex(ColumnRefExpression columnRef, TableSc #region Index Scan Selection + // Bounded table-tree fallback after existing equality and secondary-index choices. + private IOperator? TryBuildPrimaryKeyRangeScan( + string tableName, + Expression where, + TableSchema schema, + out Expression? remaining) + { + remaining = where; + // A materialized CTE can shadow a persistent table with a different row shape. + if (_cteData?.ContainsKey(tableName) == true) + return null; + + TableSchema? baseSchema = _catalog.GetTable(tableName); + if (baseSchema is null || + ResolvePrimaryKeyDefinition(baseSchema) is not { } primaryKey || + !UsesPhysicalIntegerPrimaryKey(baseSchema, primaryKey)) + { + return null; + } + + int primaryKeyIndex = baseSchema.PrimaryKeyColumnIndex; + if (primaryKeyIndex < 0 || primaryKeyIndex >= schema.Columns.Count) + return null; + + ExtractOrderedIndexRange( + where, schema, primaryKeyIndex, + out IndexScanRange range, out Expression? residual, out int consumedTermCount); + if (consumedTermCount == 0) + return null; + + remaining = residual; + return new PrimaryKeyRangeScanOperator( + _catalog.GetTableTree(tableName, _pager), schema, range, + GetReadSerializer(baseSchema), projection: null, GetSchemaColumnsArray(schema)); + } + /// /// Attempts to use a point/equality lookup for a WHERE clause. /// Supports extracting a compatible indexed equality term from @@ -16684,7 +16805,8 @@ private bool TryBuildIndexOrderedScan( if (stmt.OrderBy is not { Count: 1 }) return false; - if (PhysicalPlanCapture.Unwrap(currentSource) is not TableScanOperator) + IOperator physicalSource = PhysicalPlanCapture.Unwrap(currentSource); + if (physicalSource is not (TableScanOperator or PrimaryKeyRangeScanOperator)) return false; if (_cteData != null && _cteData.ContainsKey(tableRef.TableName)) @@ -16716,15 +16838,18 @@ private bool TryBuildIndexOrderedScan( if (orderColumn.Type is not (DbType.Integer or DbType.Text)) return false; - // INTEGER PRIMARY KEY is physically the table B+tree key, and table scan is key-ordered. + // Table scans and bounded physical-primary-key scans both retain ascending key order. int pkIdx = schema.PrimaryKeyColumnIndex; if (orderColumn.Type == DbType.Integer && - pkIdx == orderColumnIndex && - PhysicalPlanCapture.Unwrap(currentSource) is TableScanOperator) + pkIdx == orderColumnIndex) { return true; } + // Its consumed PK predicates must not be lost by replacing this bounded source. + if (physicalSource is PrimaryKeyRangeScanOperator) + return false; + if (!stmt.Limit.HasValue && !CanUseCoveredOrderedIndexScanWithoutLimit(stmt, schema, orderColumnIndex)) { @@ -17102,6 +17227,19 @@ private bool TryMaterializeSyncCompositeCoveredIndexProjectionFromCache( return true; } + private static DbValue[] DecodeRowWithNullPadding(IRecordSerializer serializer, ReadOnlySpan payload, int columnCount) + { + DbValue[] row = serializer.Decode(payload); + // Adding a nullable column without a default need not rewrite older stored records. + if (row.Length < columnCount) + { + int storedCount = row.Length; + Array.Resize(ref row, columnCount); + Array.Fill(row, DbValue.Null, storedCount, columnCount - storedCount); + } + return row; + } + private bool TryMaterializeSyncIndexedLookupResult( IOperator lookupOp, TableSchema schema, @@ -17130,7 +17268,7 @@ lookupOp is IndexScanOperator indexScan && if (projectionColumnIndices == null) { DbValue[]? row = rowPayload is { } rowPayloadMemory - ? serializer.Decode(rowPayloadMemory.Span) + ? DecodeRowWithNullPadding(serializer, rowPayloadMemory.Span, outputSchema.Length) : null; result = QueryResult.FromSyncLookup(row, outputSchema); return true; @@ -19016,7 +19154,7 @@ private ResolvedInsertIndexPlanSet GetResolvedInsertIndexPlanSet( TableSchema schema, IReadOnlyList indexes) { - if (_resolvedInsertIndexPlanCache.TryGetValue(schema, out ResolvedInsertIndexPlanSet? cached)) + if (ResolvedInsertIndexPlanCache.TryGetValue(schema, out ResolvedInsertIndexPlanSet? cached)) return cached; var fullTextIndexes = new List(); @@ -19068,7 +19206,7 @@ private ResolvedInsertIndexPlanSet GetResolvedInsertIndexPlanSet( FullTextIndexes = fullTextIndexes.Count == 0 ? [] : [.. fullTextIndexes], SqlIndexes = sqlIndexes.Count == 0 ? [] : [.. sqlIndexes], }; - _resolvedInsertIndexPlanCache.Add(schema, plans); + ResolvedInsertIndexPlanCache.Add(schema, plans); return plans; } @@ -20535,22 +20673,22 @@ private ColumnDefinition[] GetSchemaColumnsArray(TableSchema schema) if (schema.Columns is ColumnDefinition[] columnsArray) return columnsArray; - if (_tableSchemaArrayCache.TryGetValue(schema, out var cached)) + if (TableSchemaArrayCache.TryGetValue(schema, out var cached)) return cached; var created = schema.Columns.ToArray(); - _tableSchemaArrayCache[schema] = created; + TableSchemaArrayCache[schema] = created; return created; } private ColumnDefinition[] GetSingleColumnOutputSchema(TableSchema schema, int columnIndex) { var key = (schema, columnIndex); - if (_singleColumnOutputSchemaCache.TryGetValue(key, out var cached)) + if (SingleColumnOutputSchemaCache.TryGetValue(key, out var cached)) return cached; var created = new[] { schema.Columns[columnIndex] }; - _singleColumnOutputSchemaCache[key] = created; + SingleColumnOutputSchemaCache[key] = created; return created; } @@ -20815,19 +20953,19 @@ private Func GetOrCompileExpression(Expression expression, T schema.Columns, requiresQualifiedMappings ? GetQualifiedMappingsFingerprint(schema) : null); - if (_compiledExpressionCache.TryGetValue(key, out var evaluator)) + if (CompiledExpressionCache.TryGetValue(key, out var evaluator)) return evaluator; - if (_compiledExpressionCache.Count >= MaxCompiledExpressionCacheEntries) + if (CompiledExpressionCache.Count >= MaxCompiledExpressionCacheEntries) { - _compiledExpressionCache.Clear(); - _compiledSpanExpressionCache.Clear(); - _qualifiedMappingFingerprintCache.Clear(); - _requiresQualifiedMappingCache.Clear(); + _compiledExpressionCache?.Clear(); + _compiledSpanExpressionCache?.Clear(); + _qualifiedMappingFingerprintCache?.Clear(); + _requiresQualifiedMappingCache?.Clear(); } evaluator = ExpressionCompiler.Compile(expression, schema, _functions); - _compiledExpressionCache[key] = evaluator; + CompiledExpressionCache[key] = evaluator; return evaluator; } @@ -20847,19 +20985,19 @@ private SpanExpressionEvaluator GetOrCompileSpanExpression(Expression expression schema.Columns, requiresQualifiedMappings ? GetQualifiedMappingsFingerprint(schema) : null); - if (_compiledSpanExpressionCache.TryGetValue(key, out var evaluator)) + if (CompiledSpanExpressionCache.TryGetValue(key, out var evaluator)) return evaluator; - if (_compiledSpanExpressionCache.Count >= MaxCompiledExpressionCacheEntries) + if (CompiledSpanExpressionCache.Count >= MaxCompiledExpressionCacheEntries) { - _compiledExpressionCache.Clear(); - _compiledSpanExpressionCache.Clear(); - _qualifiedMappingFingerprintCache.Clear(); - _requiresQualifiedMappingCache.Clear(); + _compiledExpressionCache?.Clear(); + _compiledSpanExpressionCache?.Clear(); + _qualifiedMappingFingerprintCache?.Clear(); + _requiresQualifiedMappingCache?.Clear(); } evaluator = ExpressionCompiler.CompileSpan(expression, schema, _functions); - _compiledSpanExpressionCache[key] = evaluator; + CompiledSpanExpressionCache[key] = evaluator; return evaluator; } @@ -20876,10 +21014,10 @@ private SpanExpressionEvaluator[] GetOrCompileSpanExpressions(Expression[] expre SelectBatchPlanSlot slot, Func factory) { - if (!_selectBatchPlanCache.TryGetValue(stmt, out var cachedPlans)) + if (!SelectBatchPlanCache.TryGetValue(stmt, out var cachedPlans)) { cachedPlans = new CachedSelectBatchPlans(); - _selectBatchPlanCache[stmt] = cachedPlans; + SelectBatchPlanCache[stmt] = cachedPlans; } if (cachedPlans.TryGet(slot, out var cachedPlan)) @@ -21000,13 +21138,13 @@ private void ApplyBatchPlanPreDecodeFilters(IOperator source, IFilterProjectionB private string GetQualifiedMappingsFingerprint(TableSchema schema) { - if (_qualifiedMappingFingerprintCache.TryGetValue(schema, out var fingerprint)) + if (QualifiedMappingFingerprintCache.TryGetValue(schema, out var fingerprint)) return fingerprint; if (schema.QualifiedMappings is not { Count: > 0 } qualified) { fingerprint = string.Empty; - _qualifiedMappingFingerprintCache[schema] = fingerprint; + QualifiedMappingFingerprintCache[schema] = fingerprint; return fingerprint; } @@ -21015,17 +21153,17 @@ private string GetQualifiedMappingsFingerprint(TableSchema schema) sb.Append(pair.Key).Append('=').Append(pair.Value).Append('|'); fingerprint = sb.ToString(); - _qualifiedMappingFingerprintCache[schema] = fingerprint; + QualifiedMappingFingerprintCache[schema] = fingerprint; return fingerprint; } private bool RequiresQualifiedMappings(Expression expression) { - if (_requiresQualifiedMappingCache.TryGetValue(expression, out bool cached)) + if (RequiresQualifiedMappingCache.TryGetValue(expression, out bool cached)) return cached; bool computed = ComputeRequiresQualifiedMappings(expression); - _requiresQualifiedMappingCache[expression] = computed; + RequiresQualifiedMappingCache[expression] = computed; return computed; } @@ -21159,10 +21297,10 @@ private BTree GetTableTree(string tableName) private void InvalidateTempSystemCatalogCaches() { - _systemCatalogSchemaCache.Remove("sys.temp_tables"); - _systemCatalogSchemaCache.Remove("sys_temp_tables"); - _systemCatalogSchemaCache.Remove("sys.temp_columns"); - _systemCatalogSchemaCache.Remove("sys_temp_columns"); + _systemCatalogSchemaCache?.Remove("sys.temp_tables"); + _systemCatalogSchemaCache?.Remove("sys_temp_tables"); + _systemCatalogSchemaCache?.Remove("sys.temp_columns"); + _systemCatalogSchemaCache?.Remove("sys_temp_columns"); } private bool IsExternalTable(string tableName) => @@ -21332,10 +21470,11 @@ private void InvalidateExternalTableCaches() _externalTableRegistrationsCache = null; _systemExternalTablesRowsCache = null; _systemObjectsRowsCache = null; - _selectPlanCache.Clear(); - _selectBatchPlanCache.Clear(); - _selectJoinReorderCache.Clear(); - _selectPlanInsertionOrder.Clear(); + _selectPlanCache?.Clear(); + ClearPreparedSelectPlans(); + _selectBatchPlanCache?.Clear(); + _selectJoinReorderCache?.Clear(); + _selectPlanInsertionOrder?.Clear(); } private void ThrowIfExternalTableTarget(string tableName, string operation) @@ -21597,7 +21736,7 @@ private TableSchema GetOrCreateSystemCatalogSchema( string? alias, ColumnDefinition[] columns) { - if (alias is null && _systemCatalogSchemaCache.TryGetValue(tableNameToken, out var cached)) + if (alias is null && SystemCatalogSchemaCache.TryGetValue(tableNameToken, out var cached)) return cached; string qualifier = alias ?? tableNameToken; @@ -21613,7 +21752,7 @@ private TableSchema GetOrCreateSystemCatalogSchema( }; if (alias is null) - _systemCatalogSchemaCache[tableNameToken] = schema; + SystemCatalogSchemaCache[tableNameToken] = schema; return schema; } @@ -25280,12 +25419,16 @@ private DbValue[] ResolveInsertRow( return row; } - private DbValue[] ResolveSimpleInsertRow(TableSchema schema, int[]? explicitColumnIndices, DbValue[] values) + private DbValue[] ResolveSimpleInsertRow( + TableSchema schema, + int[]? explicitColumnIndices, + DbValue[] values, + int? knownRowVersionColumnIndex = null) { if (explicitColumnIndices is null) { ValidateInsertValueCount(schema.Columns.Count, values.Length); - int rowVersionColumnIndex = GetRowVersionColumnIndex(schema); + int rowVersionColumnIndex = knownRowVersionColumnIndex ?? GetRowVersionColumnIndex(schema); if (rowVersionColumnIndex >= 0) ThrowExplicitRowVersionAssignment(schema.Columns[rowVersionColumnIndex]); return values; @@ -25301,14 +25444,17 @@ private DbValue[] ResolveSimpleInsertRow(TableSchema schema, int[]? explicitColu useDefault[explicitColumnIndices[i]] = false; } - RejectExplicitRowVersionInsert(schema, useDefault); + RejectExplicitRowVersionInsert(schema, useDefault, knownRowVersionColumnIndex); RowConstraintValidator.ApplyDefaults(schema, row, useDefault); return row; } - private static void RejectExplicitRowVersionInsert(TableSchema schema, bool[] useDefault) + private static void RejectExplicitRowVersionInsert( + TableSchema schema, + bool[] useDefault, + int? knownRowVersionColumnIndex = null) { - int rowVersionColumnIndex = GetRowVersionColumnIndex(schema); + int rowVersionColumnIndex = knownRowVersionColumnIndex ?? GetRowVersionColumnIndex(schema); if (rowVersionColumnIndex >= 0 && !useDefault[rowVersionColumnIndex]) ThrowExplicitRowVersionAssignment(schema.Columns[rowVersionColumnIndex]); } @@ -25331,12 +25477,17 @@ private static int GetRowVersionColumnIndex(TableSchema schema) return -1; } - private async ValueTask MaterializeInitialRowVersionAsync( + private ValueTask MaterializeInitialRowVersionAsync( TableSchema schema, DbValue[] row, CancellationToken ct) + => MaterializeInitialRowVersionAsync(GetRowVersionColumnIndex(schema), row, ct); + + private async ValueTask MaterializeInitialRowVersionAsync( + int rowVersionColumnIndex, + DbValue[] row, + CancellationToken ct) { - int rowVersionColumnIndex = GetRowVersionColumnIndex(schema); if (rowVersionColumnIndex < 0) return null; @@ -26761,10 +26912,10 @@ private void RecordLogicalColumnWrites(LogicalMutationConflictMetadata metadata, private LogicalMutationConflictMetadata GetLogicalMutationConflictMetadata(string tableName, TableSchema schema) { - if (!_logicalMutationConflictMetadataCache.TryGetValue(schema, out Dictionary? schemaCache)) + if (!LogicalMutationConflictMetadataCache.TryGetValue(schema, out Dictionary? schemaCache)) { schemaCache = new Dictionary(StringComparer.OrdinalIgnoreCase); - _logicalMutationConflictMetadataCache.Add(schema, schemaCache); + LogicalMutationConflictMetadataCache.Add(schema, schemaCache); } if (!schemaCache.TryGetValue(tableName, out LogicalMutationConflictMetadata? metadata)) @@ -26937,15 +27088,26 @@ private ReadOnlyMemory EncodeRowForInsert( return buffer.AsMemory(0, written); } + private ValueTask<(long RowId, bool AutoGenerated)> ResolveRowIdForInsertAsync( + string tableName, + TableSchema schema, + BTree tree, + DbValue[] row, + int rowIdReservationCountHint, + CancellationToken ct) + => ResolveRowIdForInsertAsync( + tableName, schema, tree, row, rowIdReservationCountHint, schema.PrimaryKeyColumnIndex, ct); + private async ValueTask<(long RowId, bool AutoGenerated)> ResolveRowIdForInsertAsync( string tableName, TableSchema schema, BTree tree, DbValue[] row, int rowIdReservationCountHint, + int primaryKeyColumnIndex, CancellationToken ct) { - int pkIdx = schema.PrimaryKeyColumnIndex; + int pkIdx = primaryKeyColumnIndex; if (pkIdx >= 0 && schema.Columns[pkIdx].Type == DbType.Integer && schema.Columns[pkIdx].IsIdentity) @@ -26998,7 +27160,7 @@ private async ValueTask AllocateRowIdAsync( int rowIdReservationCountHint, CancellationToken ct) { - if (!_nextRowIdCache.TryGetValue(tableName, out long nextRowId)) + if (!NextRowIdCache.TryGetValue(tableName, out long nextRowId)) nextRowId = await LoadNextRowIdAsync(tableName, schema, tree, ct); long maximumRowId = GetGeneratedRowIdMaximum(schema); @@ -27060,13 +27222,14 @@ private static CSharpDbException IdentityExhausted( private long ReserveRowIdFromLease(string tableName, long minimumNextRowId, int rowIdReservationCountHint) { - if (_rowIdReservationLeases.TryGetValue(tableName, out RowIdReservationLease? lease) && + if (_rowIdReservationLeases is { } rowIdLeases && + rowIdLeases.TryGetValue(tableName, out RowIdReservationLease? lease) && lease.TryReserve(minimumNextRowId, out long leasedRowId)) { return leasedRowId; } - _rowIdReservationLeases.Remove(tableName); + _rowIdReservationLeases?.Remove(tableName); int reservationCount = Math.Max(1, rowIdReservationCountHint); var (start, endExclusive) = _nextRowIdRangeReservationProvider!(tableName, minimumNextRowId, reservationCount); @@ -27079,28 +27242,28 @@ private long ReserveRowIdFromLease(string tableName, long minimumNextRowId, int long nextLeaseRowId = checked(start + 1); if (nextLeaseRowId < endExclusive) - _rowIdReservationLeases[tableName] = new RowIdReservationLease(nextLeaseRowId, endExclusive); + RowIdReservationLeases[tableName] = new RowIdReservationLease(nextLeaseRowId, endExclusive); return start; } private void InvalidateRowIdCache(string tableName) { - _nextRowIdCache.Remove(tableName); - _rowIdReservationLeases.Remove(tableName); + _nextRowIdCache?.Remove(tableName); + _rowIdReservationLeases?.Remove(tableName); } private async ValueTask LoadNextRowIdAsync(string tableName, TableSchema schema, BTree tree, CancellationToken ct) { if (schema.NextRowId > 0) { - _nextRowIdCache[tableName] = schema.NextRowId; + NextRowIdCache[tableName] = schema.NextRowId; return schema.NextRowId; } if (_nextRowIdHintProvider?.Invoke(tableName) is long hintedNextRowId && hintedNextRowId > 0) { - _nextRowIdCache[tableName] = hintedNextRowId; + NextRowIdCache[tableName] = hintedNextRowId; return hintedNextRowId; } @@ -27126,12 +27289,12 @@ private void UpdateNextRowIdState(string tableName, TableSchema schema, long nex if (nextRowId <= 0) return; - if (_nextRowIdCache.TryGetValue(tableName, out long cached)) - _nextRowIdCache[tableName] = Math.Max(cached, nextRowId); + if (NextRowIdCache.TryGetValue(tableName, out long cached)) + NextRowIdCache[tableName] = Math.Max(cached, nextRowId); else - _nextRowIdCache[tableName] = nextRowId; + NextRowIdCache[tableName] = nextRowId; - _dirtyNextRowIdTables.Add(tableName); + DirtyNextRowIdTables.Add(tableName); if (_useTransientNextRowIdHints) { @@ -27149,19 +27312,20 @@ private void ObserveExplicitRowId(string tableName, TableSchema schema, long nex if (nextRowId <= 0) return; - if (_nextRowIdCache.TryGetValue(tableName, out long cached)) - _nextRowIdCache[tableName] = Math.Max(cached, nextRowId); + if (NextRowIdCache.TryGetValue(tableName, out long cached)) + NextRowIdCache[tableName] = Math.Max(cached, nextRowId); else if (schema.NextRowId > 0) - _nextRowIdCache[tableName] = Math.Max(schema.NextRowId, nextRowId); + NextRowIdCache[tableName] = Math.Max(schema.NextRowId, nextRowId); else - _nextRowIdCache[tableName] = nextRowId; + NextRowIdCache[tableName] = nextRowId; - _dirtyNextRowIdTables.Add(tableName); + DirtyNextRowIdTables.Add(tableName); _nextRowIdObservationProvider?.Invoke(tableName, nextRowId); - if (_rowIdReservationLeases.TryGetValue(tableName, out RowIdReservationLease? lease) && + if (_rowIdReservationLeases is { } rowIdLeases && + rowIdLeases.TryGetValue(tableName, out RowIdReservationLease? lease) && !lease.AdvanceTo(nextRowId)) { - _rowIdReservationLeases.Remove(tableName); + _rowIdReservationLeases?.Remove(tableName); } // Explicit rowid inserts can push the durable high-water mark forward, but persisting diff --git a/src/CSharpDB.Execution/README.md b/src/CSharpDB.Execution/README.md index 7ed73ba4..c69b4a27 100644 --- a/src/CSharpDB.Execution/README.md +++ b/src/CSharpDB.Execution/README.md @@ -22,6 +22,8 @@ Query planner, operator tree, and expression evaluator for the [CSharpDB](https: - Compiled expression cache (up to 4096 entries) for repeated queries - Trigger body caching with schema-sensitive invalidation - Sync point-lookup fast path for `SELECT ... WHERE pk = value` +- Bounded scans over physical integer primary keys for compatible range predicates, preserving residual filters and projection +- Reuse eligible prepared integer-key SELECT plans across parameter values; each execution owns its bounds and cursor, and unsupported shapes use ordinary planning - Persisted row-count reuse for `COUNT(*)`, planner cardinality estimates, skew-aware equality/`IN` estimation from heavy hitters, histogram-backed numeric range estimates, composite-prefix correlation modeling for multi-column filters/joins, public `EXPLAIN ESTIMATE FOR ` diagnostics, and bounded small-chain inner-join reordering - Broad key-only `INNER JOIN`s over declared `INTEGER` primary-key/foreign-key relationships are automatically cost-gated onto the maintained foreign-key support index. Text keys, payload or opaque projections, predicates or residuals, outer or reversed joins, `LIMIT`/`OFFSET`, and point or small scans retain the general planner path. diff --git a/src/CSharpDB.Execution/SqlTypeCoercion.cs b/src/CSharpDB.Execution/SqlTypeCoercion.cs index 44b95753..24836181 100644 --- a/src/CSharpDB.Execution/SqlTypeCoercion.cs +++ b/src/CSharpDB.Execution/SqlTypeCoercion.cs @@ -24,6 +24,33 @@ public static DbValue CoerceForAssignment( if (value.IsNull) return value; + // These already-canonical assignments need neither conversion nor + // logical facet validation. All other values use the shared boundary. + if (column.DeclaredType is { } declaredType) + { + if (value.Type == DbType.Integer) + { + if (declaredType.Kind == SqlTypeKind.BigInt || + (declaredType.Kind == SqlTypeKind.Integer && + value.AsInteger >= int.MinValue && value.AsInteger <= int.MaxValue)) + { + return value; + } + } + else if (value.Type == DbType.Text && declaredType.Kind == SqlTypeKind.Text) + { + return value; + } + } + + return CoerceForAssignmentSlow(value, column, tableName); + } + + private static DbValue CoerceForAssignmentSlow( + DbValue value, + ColumnDefinition column, + string? tableName) + { try { // Metadata written before logical descriptors existed retains the diff --git a/src/CSharpDB.Migration.SqlServer.Worker/CSharpDB.Migration.SqlServer.Worker.csproj b/src/CSharpDB.Migration.SqlServer.Worker/CSharpDB.Migration.SqlServer.Worker.csproj index 5bd39480..f3f5e324 100644 --- a/src/CSharpDB.Migration.SqlServer.Worker/CSharpDB.Migration.SqlServer.Worker.csproj +++ b/src/CSharpDB.Migration.SqlServer.Worker/CSharpDB.Migration.SqlServer.Worker.csproj @@ -12,7 +12,7 @@ - + @@ -26,7 +26,7 @@ - + diff --git a/src/CSharpDB.Migration.SqlServer/CSharpDB.Migration.SqlServer.csproj b/src/CSharpDB.Migration.SqlServer/CSharpDB.Migration.SqlServer.csproj index 64914793..5987843d 100644 --- a/src/CSharpDB.Migration.SqlServer/CSharpDB.Migration.SqlServer.csproj +++ b/src/CSharpDB.Migration.SqlServer/CSharpDB.Migration.SqlServer.csproj @@ -15,8 +15,8 @@ - - + + diff --git a/src/CSharpDB.Migration.SqlServer/README.md b/src/CSharpDB.Migration.SqlServer/README.md index 15b4dd5d..bda13a42 100644 --- a/src/CSharpDB.Migration.SqlServer/README.md +++ b/src/CSharpDB.Migration.SqlServer/README.md @@ -245,7 +245,7 @@ uses a one-way digest of the normalized endpoint and database scope. Public adapter and worker errors are generic; provider exceptions and worker standard error are not relayed because their text can contain connection material. -`Microsoft.Data.SqlClient` 7.0.3 keeps encryption mandatory by default. This +`Microsoft.Data.SqlClient` 7.1.1 keeps encryption mandatory by default. This adapter never enables `TrustServerCertificate` or weakens a stricter caller setting. Authentication modes that require `Microsoft.Data.SqlClient.Extensions.Azure` are not bundled by this checkpoint. @@ -378,7 +378,7 @@ SQL Server qualification. The resolved worker package closure is inventoried in `THIRD-PARTY-NOTICES.md`. Its MIT-licensed packages include Microsoft.Data.SqlClient and Microsoft.SqlServer.TransactSql.ScriptDom. -Microsoft.Data.SqlClient.SNI.runtime 6.0.3 is not MIT; its separate Microsoft +Microsoft.Data.SqlClient.SNI.runtime 7.1.0 is not MIT; its separate Microsoft Software License Terms accompany the worker under `licenses/`. The worker is not published as a NuGet package. It is carried only in the fixed migration release layout, whose live server, authentication, TLS, restricted-account, diff --git a/src/CSharpDB.Migration.SqlServer/THIRD-PARTY-NOTICES.md b/src/CSharpDB.Migration.SqlServer/THIRD-PARTY-NOTICES.md index 00301006..3d1ddff0 100644 --- a/src/CSharpDB.Migration.SqlServer/THIRD-PARTY-NOTICES.md +++ b/src/CSharpDB.Migration.SqlServer/THIRD-PARTY-NOTICES.md @@ -4,21 +4,21 @@ The optional SQL Server worker currently resolves the following NuGet runtime dependency closure. Packages marked MIT are covered by the copyright and MIT license terms below. `Microsoft.Data.SqlClient.SNI.runtime` uses separate Microsoft Software License Terms included at -`licenses/Microsoft.Data.SqlClient.SNI.runtime-6.0.3-LICENSE.txt`. +`licenses/Microsoft.Data.SqlClient.SNI.runtime-7.1.0-LICENSE.txt`. | Package | Version | License | | --- | --- | --- | -| Microsoft.Bcl.Cryptography | 9.0.13 | MIT | -| Microsoft.Data.SqlClient | 7.0.3 | MIT | -| Microsoft.Data.SqlClient.Extensions.Abstractions | 7.0.3 | MIT | -| Microsoft.Data.SqlClient.Internal.Logging | 7.0.3 | MIT | -| Microsoft.Data.SqlClient.SNI.runtime | 6.0.3 | Microsoft Software License Terms | -| Microsoft.Extensions.Caching.Abstractions | 9.0.13 | MIT | -| Microsoft.Extensions.Caching.Memory | 9.0.13 | MIT | +| Microsoft.Bcl.Cryptography | 9.0.18 | MIT | +| Microsoft.Data.SqlClient | 7.1.1 | MIT | +| Microsoft.Data.SqlClient.Extensions.Abstractions | 7.1.1 | MIT | +| Microsoft.Data.SqlClient.Internal.Logging | 7.1.1 | MIT | +| Microsoft.Data.SqlClient.SNI.runtime | 7.1.0 | Microsoft Software License Terms | +| Microsoft.Extensions.Caching.Abstractions | 9.0.18 | MIT | +| Microsoft.Extensions.Caching.Memory | 9.0.18 | MIT | | Microsoft.Extensions.DependencyInjection.Abstractions | 10.0.0 | MIT | | Microsoft.Extensions.Logging.Abstractions | 10.0.0 | MIT | -| Microsoft.Extensions.Options | 9.0.13 | MIT | -| Microsoft.Extensions.Primitives | 9.0.13 | MIT | +| Microsoft.Extensions.Options | 9.0.18 | MIT | +| Microsoft.Extensions.Primitives | 9.0.18 | MIT | | Microsoft.IdentityModel.Abstractions | 8.16.0 | MIT | | Microsoft.IdentityModel.JsonWebTokens | 8.16.0 | MIT | | Microsoft.IdentityModel.Logging | 8.16.0 | MIT | @@ -26,17 +26,18 @@ Microsoft Software License Terms included at | Microsoft.IdentityModel.Protocols.OpenIdConnect | 8.16.0 | MIT | | Microsoft.IdentityModel.Tokens | 8.16.0 | MIT | | Microsoft.SqlServer.Server | 1.0.0 | MIT | -| Microsoft.SqlServer.TransactSql.ScriptDom | 180.107.0 | MIT | -| System.Configuration.ConfigurationManager | 9.0.13 | MIT | -| System.Diagnostics.EventLog | 9.0.13 | MIT | +| Microsoft.SqlServer.TransactSql.ScriptDom | 180.117.0 | MIT | +| System.Configuration.ConfigurationManager | 9.0.18 | MIT | +| System.Diagnostics.EventLog | 9.0.18 | MIT | | System.IdentityModel.Tokens.Jwt | 8.16.0 | MIT | -| System.Security.Cryptography.Pkcs | 9.0.13 | MIT | -| System.Security.Cryptography.ProtectedData | 9.0.13 | MIT | +| System.Security.Cryptography.Pkcs | 9.0.18 | MIT | +| System.Security.Cryptography.ProtectedData | 9.0.18 | MIT | +| System.Threading.RateLimiting | 9.0.18 | MIT | ## Microsoft.Data.SqlClient - Package: Microsoft.Data.SqlClient -- Version: 7.0.3 +- Version: 7.1.1 - Project: https://github.com/dotnet/SqlClient - License: MIT @@ -63,7 +64,7 @@ SOFTWARE. ## Microsoft.SqlServer.TransactSql.ScriptDom - Package: Microsoft.SqlServer.TransactSql.ScriptDom -- Version: 180.107.0 +- Version: 180.117.0 - Project: https://github.com/microsoft/SqlScriptDOM - License: MIT diff --git a/src/CSharpDB.Storage/BTree/BTree.cs b/src/CSharpDB.Storage/BTree/BTree.cs index 038d1a93..0e1416d1 100644 --- a/src/CSharpDB.Storage/BTree/BTree.cs +++ b/src/CSharpDB.Storage/BTree/BTree.cs @@ -117,7 +117,7 @@ public static async ValueTask CreateNewAsync(Pager pager, CancellationToke var page = await pager.GetPageAsync(pageId, ct); var sp = new SlottedPage(page, pageId); sp.Initialize(PageConstants.PageTypeLeaf); - await pager.MarkDirtyAsync(pageId, ct); + await pager.MarkDirtyAsync(pageId, sp.Buffer, ct); return pageId; } @@ -703,7 +703,7 @@ public async ValueTask InsertAsync( Span rootCell = stackalloc byte[13]; WriteInteriorCell(rootCell, _rootPageId, result.SplitKey); sp.InsertCell(0, rootCell); - await _pager.MarkDirtyAsync(newRootId, ct); + await _pager.MarkDirtyAsync(newRootId, sp.Buffer, ct); _rootPageId = newRootId; _pager.RecordBTreeRootSplit(_logicalResourceName); @@ -791,7 +791,7 @@ public async ValueTask ReplaceAsync( Span rootCell = stackalloc byte[13]; WriteInteriorCell(rootCell, _rootPageId, result.InsertResult.SplitKey); sp.InsertCell(0, rootCell); - await _pager.MarkDirtyAsync(newRootId, ct); + await _pager.MarkDirtyAsync(newRootId, sp.Buffer, ct); _rootPageId = newRootId; _pager.RecordBTreeRootSplit(_logicalResourceName); @@ -848,11 +848,12 @@ internal BTreeCursor CreateCursor(int maxResolvedPayloadBytes) /// /// Create a cursor positioned before the first entry and record a logical read range. + /// The upper bound limits speculative read-ahead, not cursor traversal. /// public BTreeCursor CreateCursor(IndexScanRange range) { RecordLogicalRangeRead(range); - return new BTreeCursor(this, _pager); + return new BTreeCursor(this, _pager, prefetchUpperBound: range.UpperBound); } /// @@ -1159,7 +1160,7 @@ private async ValueTask TryInsertIntoLeafWithoutSplitAsync( return false; overflowInstallation?.MarkInstalled(); - await _pager.MarkDirtyAsync(pageId, ct); + await _pager.MarkDirtyAsync(pageId, sp.Buffer, ct); return true; } @@ -1172,7 +1173,7 @@ private async ValueTask TryInsertIntoLeafWithoutSplitAsync( return false; overflowInstallation?.MarkInstalled(); - await _pager.MarkDirtyAsync(pageId, ct); + await _pager.MarkDirtyAsync(pageId, sp.Buffer, ct); return true; } finally @@ -1354,7 +1355,7 @@ private async ValueTask ReplaceRecursiveAsync( payloadIsOverflow)) { overflowInstallation?.MarkInstalled(); - await _pager.MarkDirtyAsync(pageId, ct); + await _pager.MarkDirtyAsync(pageId, sp.Buffer, ct); return new ReplaceResult( found: true, new InsertResult { Split = false }, @@ -1521,8 +1522,8 @@ private async ValueTask SplitLeafAsync( { overflowInstallation?.MarkInstalled(); sp.RightChildOrNextLeaf = appendPageId; - await _pager.MarkDirtyAsync(pageId, ct); - await _pager.MarkDirtyAsync(appendPageId, ct); + await _pager.MarkDirtyAsync(pageId, sp.Buffer, ct); + await _pager.MarkDirtyAsync(appendPageId, appendSp.Buffer, ct); _pager.RecordBTreeLeafSplit(_logicalResourceName, rightEdgeSplit); UpdateRightEdgeLeafHint(appendPageId, appendSp); long appendSplitKey = ReadLeafCellKey(newCell.AsSpan(0, newCellLength)); @@ -1591,8 +1592,8 @@ private async ValueTask SplitLeafAsync( overflowInstallation?.MarkInstalled(); } - await _pager.MarkDirtyAsync(pageId, ct); - await _pager.MarkDirtyAsync(newPageId, ct); + await _pager.MarkDirtyAsync(pageId, sp.Buffer, ct); + await _pager.MarkDirtyAsync(newPageId, newSp.Buffer, ct); _pager.RecordBTreeLeafSplit(_logicalResourceName, rightEdgeSplit); if (rightEdgeSplit) @@ -1684,7 +1685,7 @@ private async ValueTask InsertIntoInteriorAsync(uint pageId, byte[ if (sp.InsertCell(afterChildIdx, cell)) { - await _pager.MarkDirtyAsync(pageId, ct); + await _pager.MarkDirtyAsync(pageId, sp.Buffer, ct); return new InsertResult { Split = false }; } @@ -1750,8 +1751,8 @@ private async ValueTask SplitInteriorAsync( for (int i = mid + 1; i < totalCellCount; i++) newSp.InsertCell(i - mid - 1, splitCellBuffer.AsSpan(i * interiorCellSize, interiorCellSize)); - await _pager.MarkDirtyAsync(pageId, ct); - await _pager.MarkDirtyAsync(newPageId, ct); + await _pager.MarkDirtyAsync(pageId, sp.Buffer, ct); + await _pager.MarkDirtyAsync(newPageId, newSp.Buffer, ct); _pager.RecordBTreeInteriorSplit(_logicalResourceName, rightEdgeSplit); return new InsertResult { Split = true, SplitKey = promotedKey, NewPageId = newPageId }; @@ -1842,7 +1843,7 @@ private async ValueTask DeleteRecursiveAsync( byte[]? deletedOverflowReference = CopyOverflowReference(sp, idx); sp.DeleteCell(idx); sp.Defragment(); - await _pager.MarkDirtyAsync(pageId, ct); + await _pager.MarkDirtyAsync(pageId, sp.Buffer, ct); bool underflow = !isRoot && sp.CellCount < MinLeafCells; return new DeleteResult(deleted: true, underflow, deletedOverflowReference); } @@ -1933,9 +1934,9 @@ private async ValueTask RebalanceUnderflowedLeafChildAsync( leftSp.Defragment(); WriteInteriorKey(parentSp, childIndex - 1, ReadLeafKey(childSp, 0)); - await _pager.MarkDirtyAsync(leftPageId, ct); - await _pager.MarkDirtyAsync(childPageId, ct); - await _pager.MarkDirtyAsync(parentPageId, ct); + await _pager.MarkDirtyAsync(leftPageId, leftSp.Buffer, ct); + await _pager.MarkDirtyAsync(childPageId, childSp.Buffer, ct); + await _pager.MarkDirtyAsync(parentPageId, parentSp.Buffer, ct); return; } } @@ -1961,9 +1962,9 @@ private async ValueTask RebalanceUnderflowedLeafChildAsync( rightSp.Defragment(); WriteInteriorKey(parentSp, childIndex, ReadLeafKey(rightSp, 0)); - await _pager.MarkDirtyAsync(childPageId, ct); - await _pager.MarkDirtyAsync(rightPageId, ct); - await _pager.MarkDirtyAsync(parentPageId, ct); + await _pager.MarkDirtyAsync(childPageId, childSp.Buffer, ct); + await _pager.MarkDirtyAsync(rightPageId, rightSp.Buffer, ct); + await _pager.MarkDirtyAsync(parentPageId, parentSp.Buffer, ct); return; } } @@ -1985,8 +1986,8 @@ private async ValueTask RebalanceUnderflowedLeafChildAsync( leftSp.RightChildOrNextLeaf = childSp.RightChildOrNextLeaf; RemoveInteriorKeyAndChild(parentSp, keyIndexToRemove: childIndex - 1, childIndexToRemove: childIndex); - await _pager.MarkDirtyAsync(leftPageId, ct); - await _pager.MarkDirtyAsync(parentPageId, ct); + await _pager.MarkDirtyAsync(leftPageId, leftSp.Buffer, ct); + await _pager.MarkDirtyAsync(parentPageId, parentSp.Buffer, ct); await _pager.FreePageAsync(childPageId, ct); return; } @@ -2008,8 +2009,8 @@ private async ValueTask RebalanceUnderflowedLeafChildAsync( childSp.RightChildOrNextLeaf = rightSp.RightChildOrNextLeaf; RemoveInteriorKeyAndChild(parentSp, keyIndexToRemove: childIndex, childIndexToRemove: childIndex + 1); - await _pager.MarkDirtyAsync(childPageId, ct); - await _pager.MarkDirtyAsync(parentPageId, ct); + await _pager.MarkDirtyAsync(childPageId, childSp.Buffer, ct); + await _pager.MarkDirtyAsync(parentPageId, parentSp.Buffer, ct); await _pager.FreePageAsync(rightPageId, ct); } } @@ -2028,7 +2029,7 @@ private async ValueTask CollapseUnderflowedInteriorChildAsync( // Collapsing a non-root interior child into one of its descendants creates mixed-height // siblings under the parent. Keep the underfull interior page in place until full // interior-node redistribution/merge exists. - await _pager.MarkDirtyAsync(childPageId, ct); + await _pager.MarkDirtyAsync(childPageId, childSp.Buffer, ct); } private async ValueTask CollapseRootIfNeededAsync(CancellationToken ct) @@ -2048,7 +2049,7 @@ private async ValueTask CollapseRootIfNeededAsync(CancellationToken ct) if (promotedRootPageId == PageConstants.NullPageId) { rootSp.Initialize(PageConstants.PageTypeLeaf); - await _pager.MarkDirtyAsync(_rootPageId, ct); + await _pager.MarkDirtyAsync(_rootPageId, rootSp.Buffer, ct); return; } diff --git a/src/CSharpDB.Storage/BTree/BTreeCursor.cs b/src/CSharpDB.Storage/BTree/BTreeCursor.cs index 6d38a480..a53ca15a 100644 --- a/src/CSharpDB.Storage/BTree/BTreeCursor.cs +++ b/src/CSharpDB.Storage/BTree/BTreeCursor.cs @@ -12,6 +12,7 @@ public sealed class BTreeCursor : IAsyncDisposable private readonly BTree _tree; private readonly Pager _pager; private readonly int _maxResolvedPayloadBytes; + private readonly long? _prefetchUpperBound; private uint _currentPageId; private int _currentIndex; private PageReadBuffer _currentLeafPage; @@ -27,12 +28,14 @@ public sealed class BTreeCursor : IAsyncDisposable internal BTreeCursor( BTree tree, Pager pager, - int maxResolvedPayloadBytes = int.MaxValue) + int maxResolvedPayloadBytes = int.MaxValue, + long? prefetchUpperBound = null) { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxResolvedPayloadBytes); _tree = tree; _pager = pager; _maxResolvedPayloadBytes = maxResolvedPayloadBytes; + _prefetchUpperBound = prefetchUpperBound; _currentPageId = 0; _currentIndex = -1; _currentLeafPage = default; @@ -248,9 +251,11 @@ private async ValueTask GetCurrentLeafAsync(CancellationTok if (_prefetchedLeafTask is not null && _prefetchedLeafPageId == _currentPageId) { + bool consumedPrefetch = false; try { _currentLeafPage = await _prefetchedLeafTask.WaitAsync(ct); + consumedPrefetch = true; } catch (OperationCanceledException) { @@ -262,7 +267,17 @@ private async ValueTask GetCurrentLeafAsync(CancellationTok } finally { - ClearPrefetchedLeaf(); + if (consumedPrefetch) + { + // The read has finished. Keep its uncancelled source so the + // next prefetch can reuse the cancellation registrations. + _prefetchedLeafPageId = PageConstants.NullPageId; + _prefetchedLeafTask = null; + } + else + { + ClearPrefetchedLeaf(); + } } } else @@ -293,6 +308,16 @@ private void ScheduleLeafPrefetch(uint nextLeafPageId) return; } + // Later leaves contain strictly larger keys. If this leaf reaches the + // requested upper bound, reading ahead cannot help that range. This is + // only a prefetch hint: callers can still move or seek past the bound. + if (_prefetchUpperBound.HasValue && _currentLeaf.CellCount > 0 && + BTree.ReadLeafKey(_currentLeaf, _currentLeaf.CellCount - 1) >= _prefetchUpperBound.Value) + { + ClearPrefetchedLeaf(); + return; + } + if (_prefetchedLeafTask is not null && _prefetchedLeafPageId == nextLeafPageId) return; @@ -302,8 +327,12 @@ private void ScheduleLeafPrefetch(uint nextLeafPageId) return; } + // Never replace a still-owned read with one using the same source. + if (_prefetchedLeafTask is not null) + ClearPrefetchedLeaf(); + _prefetchedLeafPageId = nextLeafPageId; - _prefetchedLeafCancellationSource = new CancellationTokenSource(); + _prefetchedLeafCancellationSource ??= new CancellationTokenSource(); _prefetchedLeafTask = _pager.ReadPageUncachedAsync(nextLeafPageId, _prefetchedLeafCancellationSource.Token).AsTask(); } diff --git a/src/CSharpDB.Storage/Catalog/CatalogReadSnapshot.cs b/src/CSharpDB.Storage/Catalog/CatalogReadSnapshot.cs new file mode 100644 index 00000000..5216b719 --- /dev/null +++ b/src/CSharpDB.Storage/Catalog/CatalogReadSnapshot.cs @@ -0,0 +1,549 @@ +using System.Collections.Immutable; +using CSharpDB.Primitives; + +namespace CSharpDB.Storage.Catalog; + +internal readonly record struct CatalogReadTable( + TableSchema Schema, + uint RootPage, + IReadOnlyList ForeignKeys, + IReadOnlyList ReferencingForeignKeys); + +internal readonly record struct CatalogReadIndex(IndexSchema Schema, uint RootPage); + +/// +/// Detached catalog metadata for one committed snapshot. It owns no pager, +/// cursor, table tree, index store, or reader lease. Published instances are read-only. +/// +internal sealed class CatalogReadSnapshot +{ + internal CatalogReadSnapshot(CatalogReadMetadata metadata, ulong rowVersionHighWater, + uint pageCount, uint schemaRootPage, uint freelistHead, uint changeCounter) + { + Metadata = metadata; + RowVersionHighWater = rowVersionHighWater; + PageCount = pageCount; + SchemaRootPage = schemaRootPage; + FreelistHead = freelistHead; + ChangeCounter = changeCounter; + } + + internal CatalogReadMetadata Metadata { get; } + internal long SchemaVersion => Metadata.SchemaVersion; + internal ulong RowVersionHighWater { get; } + internal uint PageCount { get; } + internal uint SchemaRootPage { get; } + internal uint FreelistHead { get; } + internal uint ChangeCounter { get; } + internal void ApplyHeader(Pager pager) => pager.ApplySnapshotHeader(PageCount, SchemaRootPage, FreelistHead, ChangeCounter); + internal SchemaCatalog CreateReaderView(Pager pager) => new(this, pager); + internal bool TryGetReadTable(string name, out CatalogReadTable table) => Metadata.TryGetReadTable(name, out table); + internal TableSchema? GetTable(string name) => Metadata.GetTable(name); + internal uint GetTableRootPage(string name) => Metadata.GetTableRootPage(name); + internal IReadOnlyCollection GetTableNames() => Metadata.GetTableNames(); + internal IReadOnlyList GetForeignKeysForTable(string name) => Metadata.GetForeignKeysForTable(name); + internal IReadOnlyList GetReferencingForeignKeys(string name) => Metadata.GetReferencingForeignKeys(name); + internal TableStatistics? GetTableStatistics(string name) => Metadata.GetTableStatistics(name); + internal IReadOnlyCollection GetTableStatistics() => Metadata.GetTableStatistics(); + internal ColumnStatistics? GetColumnStatistics(string table, string column) => Metadata.GetColumnStatistics(table, column); + internal IReadOnlyCollection GetColumnStatistics(string table) => Metadata.GetColumnStatistics(table); + internal IReadOnlyCollection GetColumnStatistics() => Metadata.GetColumnStatistics(); + internal IReadOnlyCollection GetColumnDistributionStatistics() => Metadata.GetColumnDistributionStatistics(); + internal IReadOnlyCollection GetIndexPrefixStatistics() => Metadata.GetIndexPrefixStatistics(); + internal bool TryGetEstimatedTableRowCount(string table, out long count) => Metadata.TryGetEstimatedTableRowCount(table, out count); + internal bool TryGetExactTableRowCount(string table, out long count) => Metadata.TryGetExactTableRowCount(table, out count); + internal bool TryGetFreshColumnStatistics(string table, string column, out ColumnStatistics statistics) + => Metadata.TryGetFreshColumnStatistics(table, column, out statistics); + internal bool TryGetFreshColumnDistributionStatistics(string table, string column, out ColumnDistributionStatistics statistics) + => Metadata.TryGetFreshColumnDistributionStatistics(table, column, out statistics); + internal bool TryGetFreshIndexPrefixStatistics(string name, out IndexPrefixStatistics statistics) + => Metadata.TryGetFreshIndexPrefixStatistics(name, out statistics); + internal BTree GetTableTree(string table, Pager pager) => Metadata.GetTableTree(table, pager); + internal ValueTask GetExactTableRowCountAsync(string table, Pager pager, CancellationToken ct) + => Metadata.GetExactTableRowCountAsync(table, pager, ct); + internal IndexSchema? GetIndex(string name) => Metadata.GetIndex(name); + internal IReadOnlyCollection GetIndexes() => Metadata.GetIndexes(); + internal IReadOnlyList GetIndexesForTable(string table) => Metadata.GetIndexesForTable(table); + internal IReadOnlyList GetSqlIndexesForTable(string table) => Metadata.GetSqlIndexesForTable(table); + internal IIndexStore GetIndexStore(string name, Pager pager) => Metadata.GetIndexStore(name, pager); + internal string? GetViewSql(string name) => Metadata.GetViewSql(name); + internal IReadOnlyCollection GetViewNames() => Metadata.GetViewNames(); + internal bool IsView(string name) => Metadata.IsView(name); + internal TriggerSchema? GetTrigger(string name) => Metadata.GetTrigger(name); + internal IReadOnlyCollection GetTriggers() => Metadata.GetTriggers(); + internal IReadOnlyList GetTriggersForTable(string table) => Metadata.GetTriggersForTable(table); +} + +// Header-only commits share these detached maps without recopying their fields. +internal sealed class CatalogReadMetadata +{ + private readonly ImmutableDictionary _tables; + private readonly ImmutableDictionary _indexes; + private readonly ImmutableDictionary _views; + private readonly ImmutableDictionary _triggers; + private readonly ImmutableDictionary _tableStatistics; + private readonly ImmutableHashSet _exactTableRowCounts; + private readonly ImmutableDictionary _columnStatistics; + private readonly ImmutableDictionary _columnDistributions; + private readonly ImmutableDictionary _indexPrefixes; + private readonly Dictionary _indexesByTable; + private readonly Dictionary _sqlIndexesByTable; + private readonly Dictionary _triggersByTable; + private Dictionary? _columnsByTable; + private readonly IndexSchema[] _allIndexes; + private readonly TriggerSchema[] _allTriggers; + private TableStatistics[]? _allTableStatistics; + private ColumnStatistics[]? _allColumnStatistics; + private ColumnDistributionStatistics[]? _allColumnDistributions; + private IndexPrefixStatistics[]? _allIndexPrefixes; + private readonly IIndexProvider _indexProvider; + private string[]? _tableNames; + private string[]? _viewNames; + + internal CatalogReadMetadata( + CatalogReadMetadata? previous, + long schemaVersion, + IIndexProvider indexProvider, + Dictionary tables, + Dictionary tableRoots, + Func> getForeignKeys, + Func> getReferencingForeignKeys, + Dictionary indexes, + Dictionary indexRoots, + Dictionary views, + Dictionary triggers, + Dictionary tableStatistics, + HashSet exactTableRowCounts, + bool trustExactRowCounts, + Dictionary columnStatistics, + Dictionary columnDistributions, + Dictionary indexPrefixes) + { + SchemaVersion = schemaVersion; + _indexProvider = indexProvider; + + bool sameLogicalSchema = previous is not null && previous.SchemaVersion == schemaVersion && + previous._tables.Count == tables.Count; + if (sameLogicalSchema) + { + foreach (var pair in tables) + { + if (!previous!._tables.TryGetValue(pair.Key, out var oldTable) || + !SameTableDefinition(oldTable.Schema, pair.Value)) + { + sameLogicalSchema = false; + break; + } + } + } + + var capturedTables = previous?._tables ?? EmptyMap(); + foreach (var pair in tables) + { + bool found = capturedTables.TryGetValue(pair.Key, out var oldTable); + TableSchema schema = found && SameTableDefinition(oldTable.Schema, pair.Value) && + oldTable.Schema.NextRowId == pair.Value.NextRowId + ? oldTable.Schema + : CloneTable(pair.Value); + var foreignKeys = found && sameLogicalSchema ? oldTable.ForeignKeys : getForeignKeys(pair.Key); + var references = found && sameLogicalSchema ? oldTable.ReferencingForeignKeys : getReferencingForeignKeys(pair.Key); + var table = new CatalogReadTable(schema, tableRoots[pair.Key], foreignKeys, references); + if (!found || table != oldTable) + capturedTables = capturedTables.SetItem(pair.Key, table); + } + _tables = RemoveMissing(capturedTables, tables); + + var capturedIndexes = previous?._indexes ?? EmptyMap(); + bool sameIndexSchemas = previous is not null && capturedIndexes.Count == indexes.Count; + foreach (var pair in indexes) + { + bool found = capturedIndexes.TryGetValue(pair.Key, out var oldIndex); + sameIndexSchemas &= found && ReferenceEquals(oldIndex.Schema, pair.Value); + var index = new CatalogReadIndex(pair.Value, indexRoots[pair.Key]); + if (!found || index != oldIndex) + capturedIndexes = capturedIndexes.SetItem(pair.Key, index); + } + _indexes = RemoveMissing(capturedIndexes, indexes); + _views = CaptureMap(previous?._views, views); + _triggers = CaptureMap(previous?._triggers, triggers); + _tableStatistics = CaptureMap(previous?._tableStatistics, tableStatistics); + _exactTableRowCounts = trustExactRowCounts + ? CaptureSet(previous?._exactTableRowCounts, exactTableRowCounts) + : ImmutableHashSet.Create(StringComparer.OrdinalIgnoreCase); + _columnStatistics = CaptureMap(previous?._columnStatistics, columnStatistics); + _columnDistributions = CaptureMap(previous?._columnDistributions, columnDistributions); + _indexPrefixes = CaptureMap(previous?._indexPrefixes, indexPrefixes); + + if (sameIndexSchemas) + { + _allIndexes = previous!._allIndexes; + _indexesByTable = previous._indexesByTable; + _sqlIndexesByTable = previous._sqlIndexesByTable; + } + else + { + _allIndexes = indexes.Values.ToArray(); + _indexesByTable = GroupByTable(_allIndexes, entry => entry.TableName); + _sqlIndexesByTable = GroupByTable(_allIndexes.Where(entry => entry.Kind is IndexKind.Sql or IndexKind.ConstraintInternal), entry => entry.TableName); + } + if (ReferenceEquals(_triggers, previous?._triggers)) + { + _allTriggers = previous!._allTriggers; + _triggersByTable = previous._triggersByTable; + } + else + { + _allTriggers = triggers.Values.ToArray(); + _triggersByTable = GroupByTable(_allTriggers, entry => entry.TableName); + } + if (ReferenceEquals(_columnStatistics, previous?._columnStatistics)) + { + _allColumnStatistics = Volatile.Read(ref previous!._allColumnStatistics); + _columnsByTable = Volatile.Read(ref previous._columnsByTable); + } + else + { + _allColumnStatistics = columnStatistics.Values.ToArray(); + _columnsByTable = GroupByTable(_allColumnStatistics, entry => entry.TableName); + } + if (ReferenceEquals(_tableStatistics, previous?._tableStatistics)) + _allTableStatistics = Volatile.Read(ref previous!._allTableStatistics); + if (ReferenceEquals(_columnDistributions, previous?._columnDistributions)) + _allColumnDistributions = Volatile.Read(ref previous!._allColumnDistributions); + if (ReferenceEquals(_indexPrefixes, previous?._indexPrefixes)) + _allIndexPrefixes = Volatile.Read(ref previous!._allIndexPrefixes); + if (ReferenceEquals(_tables, previous?._tables)) + _tableNames = Volatile.Read(ref previous!._tableNames); + if (ReferenceEquals(_views, previous?._views)) + _viewNames = Volatile.Read(ref previous!._viewNames); + } + + internal CatalogReadMetadata CaptureChanges( + CatalogReadChanges changes, + Dictionary tables, + Dictionary tableRoots, + Dictionary indexRoots, + Dictionary tableStatistics, + HashSet exactTableRowCounts, + Dictionary columnStatistics) + { + var capturedTables = _tables; + foreach (string name in changes.Tables) + { + CatalogReadTable previous = _tables[name]; + TableSchema live = tables[name]; + TableSchema schema = SameTableDefinition(previous.Schema, live) && previous.Schema.NextRowId == live.NextRowId + ? previous.Schema : CloneTable(live); + var table = new CatalogReadTable(schema, tableRoots[name], previous.ForeignKeys, previous.ReferencingForeignKeys); + if (table != previous) + capturedTables = capturedTables.SetItem(name, table); + } + var capturedIndexes = _indexes; + foreach (string name in changes.Indexes) + { + CatalogReadIndex previous = _indexes[name]; + uint root = indexRoots[name]; + if (previous.RootPage != root) + capturedIndexes = capturedIndexes.SetItem(name, new(previous.Schema, root)); + } + var capturedStatistics = CaptureChangedMap(_tableStatistics, tableStatistics, changes.TableStatistics); + var capturedExactCounts = _exactTableRowCounts; + foreach (string name in changes.TableStatistics) + capturedExactCounts = exactTableRowCounts.Contains(name) + ? capturedExactCounts.Add(name) : capturedExactCounts.Remove(name); + var capturedColumns = CaptureChangedMap(_columnStatistics, columnStatistics, changes.ColumnStatistics); + + if (ReferenceEquals(capturedTables, _tables) && ReferenceEquals(capturedIndexes, _indexes) && + ReferenceEquals(capturedStatistics, _tableStatistics) && ReferenceEquals(capturedExactCounts, _exactTableRowCounts) && + ReferenceEquals(capturedColumns, _columnStatistics)) + return this; + + return new CatalogReadMetadata(this, capturedTables, capturedIndexes, capturedStatistics, capturedExactCounts, capturedColumns); + } + + private CatalogReadMetadata(CatalogReadMetadata previous, + ImmutableDictionary tables, + ImmutableDictionary indexes, + ImmutableDictionary tableStatistics, + ImmutableHashSet exactTableRowCounts, + ImmutableDictionary columnStatistics) + { + SchemaVersion = previous.SchemaVersion; + _tables = tables; + _indexes = indexes; + _tableStatistics = tableStatistics; + _exactTableRowCounts = exactTableRowCounts; + _columnStatistics = columnStatistics; + _views = previous._views; + _triggers = previous._triggers; + _columnDistributions = previous._columnDistributions; + _indexPrefixes = previous._indexPrefixes; + _indexesByTable = previous._indexesByTable; + _sqlIndexesByTable = previous._sqlIndexesByTable; + _triggersByTable = previous._triggersByTable; + _allIndexes = previous._allIndexes; + _allTriggers = previous._allTriggers; + _indexProvider = previous._indexProvider; + _tableNames = Volatile.Read(ref previous._tableNames); + _viewNames = Volatile.Read(ref previous._viewNames); + _allColumnDistributions = Volatile.Read(ref previous._allColumnDistributions); + _allIndexPrefixes = Volatile.Read(ref previous._allIndexPrefixes); + if (ReferenceEquals(tableStatistics, previous._tableStatistics)) + _allTableStatistics = Volatile.Read(ref previous._allTableStatistics); + if (ReferenceEquals(columnStatistics, previous._columnStatistics)) + { + _allColumnStatistics = Volatile.Read(ref previous._allColumnStatistics); + _columnsByTable = Volatile.Read(ref previous._columnsByTable); + } + } + + private static ImmutableDictionary CaptureChangedMap( + ImmutableDictionary captured, Dictionary source, HashSet changes) where T : class + { + foreach (string name in changes) + { + if (!source.TryGetValue(name, out T? value)) + captured = captured.Remove(name); + else if (!captured.TryGetValue(name, out T? previous) || !SameCapturedValue(previous, value)) + captured = captured.SetItem(name, value); + } + return captured; + } + + internal long SchemaVersion { get; } + internal bool TryGetReadTable(string tableName, out CatalogReadTable table) + => _tables.TryGetValue(tableName, out table); + + internal TableSchema? GetTable(string tableName) + => _tables.TryGetValue(tableName, out var table) ? table.Schema : null; + + internal uint GetTableRootPage(string tableName) + => _tables.TryGetValue(tableName, out var table) + ? table.RootPage + : throw new CSharpDbException(ErrorCode.TableNotFound, $"Table '{tableName}' not found."); + + internal IReadOnlyCollection GetTableNames() => GetArray(ref _tableNames, _tables.Keys); + internal IReadOnlyList GetForeignKeysForTable(string tableName) + => _tables.TryGetValue(tableName, out var table) ? table.ForeignKeys : Array.Empty(); + internal IReadOnlyList GetReferencingForeignKeys(string tableName) + => _tables.TryGetValue(tableName, out var table) ? table.ReferencingForeignKeys : Array.Empty(); + internal TableStatistics? GetTableStatistics(string tableName) + => _tableStatistics.GetValueOrDefault(tableName); + internal IReadOnlyCollection GetTableStatistics() => GetArray(ref _allTableStatistics, _tableStatistics.Values); + internal ColumnStatistics? GetColumnStatistics(string tableName, string columnName) + => _columnStatistics.GetValueOrDefault(ColumnKey(tableName, columnName)); + internal IReadOnlyCollection GetColumnStatistics(string tableName) + { + var grouped = Volatile.Read(ref _columnsByTable); + if (grouped is null) + { + grouped = GroupByTable(GetColumnStatistics(), entry => entry.TableName); + grouped = Interlocked.CompareExchange(ref _columnsByTable, grouped, null) ?? grouped; + } + return grouped.GetValueOrDefault(tableName) ?? Array.Empty(); + } + internal IReadOnlyCollection GetColumnStatistics() => GetArray(ref _allColumnStatistics, _columnStatistics.Values); + internal IReadOnlyCollection GetColumnDistributionStatistics() => GetArray(ref _allColumnDistributions, _columnDistributions.Values); + internal IReadOnlyCollection GetIndexPrefixStatistics() => GetArray(ref _allIndexPrefixes, _indexPrefixes.Values); + + internal bool TryGetEstimatedTableRowCount(string tableName, out long count) + { + if (_tableStatistics.TryGetValue(tableName, out var statistics)) + { + count = statistics.RowCount; + return true; + } + count = 0; + return false; + } + + internal bool TryGetExactTableRowCount(string tableName, out long count) + { + count = 0; + return _exactTableRowCounts.Contains(tableName) && TryGetEstimatedTableRowCount(tableName, out count); + } + + internal bool TryGetFreshColumnStatistics(string tableName, string columnName, out ColumnStatistics statistics) + { + if (_columnStatistics.TryGetValue(ColumnKey(tableName, columnName), out statistics!) && !statistics.IsStale) + return true; + statistics = null!; + return false; + } + + internal bool TryGetFreshColumnDistributionStatistics(string tableName, string columnName, out ColumnDistributionStatistics statistics) + { + if (_columnDistributions.TryGetValue(ColumnKey(tableName, columnName), out statistics!) && + TryGetFreshColumnStatistics(tableName, columnName, out _)) + return true; + statistics = null!; + return false; + } + + internal bool TryGetFreshIndexPrefixStatistics(string indexName, out IndexPrefixStatistics statistics) + { + if (_indexPrefixes.TryGetValue(indexName, out statistics!) && + _tableStatistics.TryGetValue(statistics.TableName, out var table) && !table.HasStaleColumns) + return true; + statistics = null!; + return false; + } + + internal BTree GetTableTree(string tableName, Pager pager) + { + var tree = new BTree(pager, GetTableRootPage(tableName), tableName); + if (TryGetEstimatedTableRowCount(tableName, out long count)) + tree.SetCachedEntryCount(count); + return tree; + } + + internal ValueTask GetExactTableRowCountAsync(string tableName, Pager pager, CancellationToken ct) + => TryGetExactTableRowCount(tableName, out long count) + ? ValueTask.FromResult(count) + : GetTableTree(tableName, pager).CountEntriesExactAsync(ct); + + internal IndexSchema? GetIndex(string indexName) + => _indexes.TryGetValue(indexName, out var index) ? index.Schema : null; + internal IReadOnlyCollection GetIndexes() => _allIndexes; + internal IReadOnlyList GetIndexesForTable(string tableName) + => _indexesByTable.GetValueOrDefault(tableName) ?? Array.Empty(); + internal IReadOnlyList GetSqlIndexesForTable(string tableName) + => _sqlIndexesByTable.GetValueOrDefault(tableName) ?? Array.Empty(); + + internal IIndexStore GetIndexStore(string indexName, Pager pager) + { + if (!_indexes.TryGetValue(indexName, out var index)) + throw new CSharpDbException(ErrorCode.TableNotFound, $"Index '{indexName}' not found."); + IIndexStore store = _indexProvider.CreateIndexStore(pager, index.RootPage, index.Schema.IndexName); + return index.Schema.Kind is IndexKind.Collection or IndexKind.Sql or IndexKind.FullTextInternal + ? new OverflowingIndexStore(store, pager) + : store; + } + + internal string? GetViewSql(string viewName) => _views.GetValueOrDefault(viewName); + internal IReadOnlyCollection GetViewNames() => GetArray(ref _viewNames, _views.Keys); + internal bool IsView(string name) => _views.ContainsKey(name); + internal TriggerSchema? GetTrigger(string triggerName) => _triggers.GetValueOrDefault(triggerName); + internal IReadOnlyCollection GetTriggers() => _allTriggers; + internal IReadOnlyList GetTriggersForTable(string tableName) + => _triggersByTable.GetValueOrDefault(tableName) ?? Array.Empty(); + + private static string ColumnKey(string tableName, string columnName) => $"{tableName}\u001F{columnName}"; + private static Dictionary GroupByTable(IEnumerable entries, Func tableName) + => entries.GroupBy(tableName, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.OrdinalIgnoreCase); + + private static ImmutableDictionary EmptyMap() + => ImmutableDictionary.Create(StringComparer.OrdinalIgnoreCase); + + private static ImmutableDictionary CaptureMap( + ImmutableDictionary? previous, Dictionary source) where T : class + { + var captured = previous ?? EmptyMap(); + foreach (var pair in source) + { + if (!captured.TryGetValue(pair.Key, out T? oldValue) || + !SameCapturedValue(oldValue, pair.Value)) + captured = captured.SetItem(pair.Key, pair.Value); + } + return RemoveMissing(captured, source); + } + + private static bool SameCapturedValue(T left, T right) where T : class + { + if (ReferenceEquals(left, right)) return true; + return (left, right) switch + { + (TableStatistics a, TableStatistics b) => + a.TableName == b.TableName && a.RowCount == b.RowCount && + a.RowCountIsExact == b.RowCountIsExact && a.HasStaleColumns == b.HasStaleColumns && + a.LastPersistedChangeCounter == b.LastPersistedChangeCounter, + (ColumnStatistics a, ColumnStatistics b) => + a.TableName == b.TableName && a.ColumnName == b.ColumnName && + a.DistinctCount == b.DistinctCount && a.NonNullCount == b.NonNullCount && + a.IsStale == b.IsStale && a.MinValue.Type == b.MinValue.Type && + a.MaxValue.Type == b.MaxValue.Type && a.MinValue == b.MinValue && a.MaxValue == b.MaxValue, + _ => EqualityComparer.Default.Equals(left, right), + }; + } + + private static ImmutableDictionary RemoveMissing( + ImmutableDictionary captured, Dictionary source) + { + foreach (var pair in captured) + if (!source.ContainsKey(pair.Key)) + captured = captured.Remove(pair.Key); + return captured; + } + + private static ImmutableHashSet CaptureSet(ImmutableHashSet? previous, HashSet source) + { + var captured = previous ?? ImmutableHashSet.Create(StringComparer.OrdinalIgnoreCase); + foreach (string value in source) + captured = captured.Add(value); + foreach (string value in captured) + if (!source.Contains(value)) + captured = captured.Remove(value); + return captured; + } + + private static bool SameTableDefinition(TableSchema left, TableSchema right) + => left.SchemaId == right.SchemaId && left.TableName == right.TableName && + ReferenceEquals(left.Columns, right.Columns) && + ReferenceEquals(left.ForeignKeys, right.ForeignKeys) && + ReferenceEquals(left.CheckConstraints, right.CheckConstraints) && + ReferenceEquals(left.KeyConstraints, right.KeyConstraints) && + SameMappings(left.QualifiedMappings, right.QualifiedMappings); + + private static bool SameMappings(Dictionary? left, Dictionary? right) + { + if (ReferenceEquals(left, right)) return true; + if (left is null || right is null || left.Count != right.Count) return false; + foreach (var pair in left) + if (!right.TryGetValue(pair.Key, out int ordinal) || ordinal != pair.Value) + return false; + return true; + } + + private static TableSchema CloneTable(TableSchema schema) => new() + { + SchemaId = schema.SchemaId, + TableName = schema.TableName, + Columns = schema.Columns, + ForeignKeys = schema.ForeignKeys, + CheckConstraints = schema.CheckConstraints, + KeyConstraints = schema.KeyConstraints, + NextRowId = schema.NextRowId, + QualifiedMappings = schema.QualifiedMappings is null + ? null + : new Dictionary(schema.QualifiedMappings, schema.QualifiedMappings.Comparer), + }; + + private static T[] GetArray(ref T[]? cache, IEnumerable source) + { + T[]? array = Volatile.Read(ref cache); + if (array is not null) return array; + array = source.ToArray(); + return Interlocked.CompareExchange(ref cache, array, null) ?? array; + } + +} + +// Separate from persistence dirty queues: entries survive flushes and are consumed +// only by a successful coherent capture. Overflow switches to a full capture. +internal sealed class CatalogReadChanges +{ + internal const int MaximumEntries = 128; + internal readonly HashSet Tables = new(StringComparer.OrdinalIgnoreCase); + internal readonly HashSet Indexes = new(StringComparer.OrdinalIgnoreCase); + internal readonly HashSet TableStatistics = new(StringComparer.OrdinalIgnoreCase); + internal readonly HashSet ColumnStatistics = new(StringComparer.OrdinalIgnoreCase); + internal int Count => Tables.Count + Indexes.Count + TableStatistics.Count + ColumnStatistics.Count; + internal void Clear() + { + Tables.Clear(); + Indexes.Clear(); + TableStatistics.Clear(); + ColumnStatistics.Clear(); + } +} diff --git a/src/CSharpDB.Storage/Catalog/CatalogService.cs b/src/CSharpDB.Storage/Catalog/CatalogService.cs index f06101c5..458343e0 100644 --- a/src/CSharpDB.Storage/Catalog/CatalogService.cs +++ b/src/CSharpDB.Storage/Catalog/CatalogService.cs @@ -29,6 +29,10 @@ internal sealed class CatalogService private readonly CatalogCache _cacheState = new(); private BTree? _catalogTree; private long _schemaVersion; + private CatalogReadSnapshot? _lastReadCapture; + private CatalogReadChanges? _readCaptureChanges; + private bool _readCaptureInvalidated = true; + private bool _readCaptureTrustsExactCounts; private ulong _rowVersionHighWater; private IndexSchema[] _indexesSnapshot = Array.Empty(); private string[] _viewNamesSnapshot = Array.Empty(); @@ -170,11 +174,59 @@ public static async ValueTask CreateAsync( } public long SchemaVersion => Volatile.Read(ref _schemaVersion); + + // The caller must serialize live-catalog mutation/publication while capturing. + // A freshly loaded private snapshot catalog is already exclusively owned. + internal CatalogReadSnapshot CaptureReadSnapshot( + CatalogReadSnapshot? previous = null, + bool trustExactRowCounts = true) + { + CatalogReadMetadata metadata; + if (!_readCaptureInvalidated && previous is not null && ReferenceEquals(previous, _lastReadCapture) && + trustExactRowCounts && _readCaptureTrustsExactCounts) + { + metadata = previous.Metadata.CaptureChanges(_readCaptureChanges!, _cache, _tableRootPages, + _indexRootPages, _tableStatsCache, _exactTableRowCounts, _columnStatsCache); + } + else + { + metadata = new CatalogReadMetadata(previous?.Metadata, SchemaVersion, _indexProvider, + _cache, _tableRootPages, GetForeignKeysForTable, GetReferencingForeignKeys, + _indexCache, _indexRootPages, _viewCache, _triggerCache, _tableStatsCache, + _exactTableRowCounts, trustExactRowCounts, _columnStatsCache, + _columnDistributionStatsCache, _indexPrefixStatsCache); + } + + var snapshot = new CatalogReadSnapshot(metadata, _rowVersionHighWater, _pager.PageCount, + _pager.SchemaRootPage, _pager.FreelistHead, _pager.ChangeCounter); + _readCaptureChanges ??= new CatalogReadChanges(); + _readCaptureChanges.Clear(); + _readCaptureInvalidated = false; + _readCaptureTrustsExactCounts = trustExactRowCounts; + _lastReadCapture = snapshot; + return snapshot; + } + + private void TrackReadCaptureChange(HashSet? changes, string name) + { + if (_readCaptureInvalidated || changes is null) + return; + if (changes.Add(name) && _readCaptureChanges!.Count > CatalogReadChanges.MaximumEntries) + InvalidateReadCapture(); + } + + private void InvalidateReadCapture() + { + _readCaptureInvalidated = true; + _readCaptureChanges?.Clear(); + } + public ulong RowVersionHighWater => _rowVersionHighWater; public bool HasAdvisoryCatalogContentChanges => _advisoryCatalogContentChanged; public async ValueTask ReloadAsync(CancellationToken ct = default) { + InvalidateReadCapture(); _catalogTree = null; _indexCatalogTree = null; _viewCatalogTree = null; @@ -761,7 +813,10 @@ public void ApplyCommittedTableMetadataSnapshot(IReadOnlyCollection payload) private void IncrementSchemaVersion() { + InvalidateReadCapture(); Interlocked.Increment(ref _schemaVersion); } @@ -4702,6 +4775,7 @@ private async ValueTask PersistTableRootPageChangeAsync(string tableName, _tableRootPages[tableName] = currentRootPage; _persistedTableNextRowIds[tableName] = schema.NextRowId; + TrackReadCaptureChange(_readCaptureChanges?.Tables, tableName); _pager.SchemaRootPage = _catalogTree.RootPageId; return currentRootPage != persistedRootPage; } @@ -4727,6 +4801,7 @@ private async ValueTask PersistIndexRootPageChangeAsync(string indexName, await _indexCatalogTree.InsertAsync(key, payload, ct); _indexRootPages[indexName] = currentRootPage; + TrackReadCaptureChange(_readCaptureChanges?.Indexes, indexName); return true; } diff --git a/src/CSharpDB.Storage/Catalog/SchemaCatalog.cs b/src/CSharpDB.Storage/Catalog/SchemaCatalog.cs index 40068b2e..86432658 100644 --- a/src/CSharpDB.Storage/Catalog/SchemaCatalog.cs +++ b/src/CSharpDB.Storage/Catalog/SchemaCatalog.cs @@ -13,7 +13,17 @@ internal readonly record struct TableAndIndexStorageReplacement( /// public sealed class SchemaCatalog { - private readonly CatalogService _service; + private readonly CatalogService? _service; + private readonly CatalogReadSnapshot? _readSnapshot; + private readonly Pager? _readPager; + private CatalogService Service => _service + ?? throw new InvalidOperationException("Snapshot catalog views are read-only."); + + internal SchemaCatalog(CatalogReadSnapshot snapshot, Pager pager) + { + _readSnapshot = snapshot; + _readPager = pager; + } private SchemaCatalog(CatalogService service) { @@ -63,121 +73,155 @@ public static async ValueTask CreateAsync( return new SchemaCatalog(service); } - public long SchemaVersion => _service.SchemaVersion; + internal CatalogReadSnapshot CaptureReadSnapshot(CatalogReadSnapshot? previous = null) + => _readSnapshot ?? Service.CaptureReadSnapshot(previous); + + internal bool TryGetReadTable(string name, out CatalogReadTable table) + { + if (_readSnapshot is not null) + return _readSnapshot.TryGetReadTable(name, out table); + TableSchema? schema = Service.GetTable(name); + if (schema is null) + { + table = default; + return false; + } + table = new(schema, Service.GetTableRootPage(name), Service.GetForeignKeysForTable(name), Service.GetReferencingForeignKeys(name)); + return true; + } + + internal static async ValueTask LoadReadSnapshotAsync( + Pager snapshotPager, + ISchemaSerializer schemaSerializer, + IIndexProvider indexProvider, + ICatalogStore catalogStore, + AdvisoryStatisticsPersistenceMode advisoryStatisticsPersistenceMode, + CancellationToken ct = default) + { + await snapshotPager.LoadSnapshotHeaderAsync(ct); + var catalog = await CreateAsync(snapshotPager, schemaSerializer, indexProvider, + catalogStore, advisoryStatisticsPersistenceMode, ct); + // Explicit writers can publish data before merging their counts into the + // shared catalog, even with immediate advisory persistence. Keep persisted + // estimates for planning, but scan the snapshot for an exact COUNT. + return catalog.Service.CaptureReadSnapshot(trustExactRowCounts: false); + } + + public long SchemaVersion => _readSnapshot?.SchemaVersion ?? Service.SchemaVersion; - public ulong RowVersionHighWater => _service.RowVersionHighWater; + public ulong RowVersionHighWater => _readSnapshot?.RowVersionHighWater ?? Service.RowVersionHighWater; public ValueTask PersistRowVersionHighWaterAsync( ulong rowVersionHighWater, CancellationToken ct = default) => - _service.PersistRowVersionHighWaterAsync(rowVersionHighWater, ct); + Service.PersistRowVersionHighWaterAsync(rowVersionHighWater, ct); - internal void MarkLogicalSchemaChanged() => _service.MarkLogicalSchemaChanged(); + internal void MarkLogicalSchemaChanged() => Service.MarkLogicalSchemaChanged(); - public TableSchema? GetTable(string tableName) => _service.GetTable(tableName); + public TableSchema? GetTable(string tableName) => _readSnapshot is null ? Service.GetTable(tableName) : _readSnapshot.GetTable(tableName); public IReadOnlyList GetForeignKeysForTable(string tableName) => - _service.GetForeignKeysForTable(tableName); + _readSnapshot is null ? Service.GetForeignKeysForTable(tableName) : _readSnapshot.GetForeignKeysForTable(tableName); public IReadOnlyList GetReferencingForeignKeys(string parentTableName) => - _service.GetReferencingForeignKeys(parentTableName); + _readSnapshot is null ? Service.GetReferencingForeignKeys(parentTableName) : _readSnapshot.GetReferencingForeignKeys(parentTableName); - public TableStatistics? GetTableStatistics(string tableName) => _service.GetTableStatistics(tableName); + public TableStatistics? GetTableStatistics(string tableName) => _readSnapshot is null ? Service.GetTableStatistics(tableName) : _readSnapshot.GetTableStatistics(tableName); - public IReadOnlyCollection GetTableStatistics() => _service.GetTableStatistics(); + public IReadOnlyCollection GetTableStatistics() => _readSnapshot is null ? Service.GetTableStatistics() : _readSnapshot.GetTableStatistics(); - public IReadOnlyCollection GetDirtyTableStatistics() => _service.GetDirtyTableStatistics(); + public IReadOnlyCollection GetDirtyTableStatistics() => _readSnapshot is null ? Service.GetDirtyTableStatistics() : Array.Empty(); public IReadOnlyCollection> GetPendingTableRowCountDeltas() => - _service.GetPendingTableRowCountDeltas(); + _readSnapshot is null ? Service.GetPendingTableRowCountDeltas() : Array.Empty>(); public ColumnStatistics? GetColumnStatistics(string tableName, string columnName) => - _service.GetColumnStatistics(tableName, columnName); + _readSnapshot is null ? Service.GetColumnStatistics(tableName, columnName) : _readSnapshot.GetColumnStatistics(tableName, columnName); public IReadOnlyCollection GetColumnStatistics(string tableName) => - _service.GetColumnStatistics(tableName); + _readSnapshot is null ? Service.GetColumnStatistics(tableName) : _readSnapshot.GetColumnStatistics(tableName); public IReadOnlyCollection GetColumnStatistics() => - _service.GetColumnStatistics(); + _readSnapshot is null ? Service.GetColumnStatistics() : _readSnapshot.GetColumnStatistics(); public IReadOnlyCollection GetDirtyColumnStatistics() => - _service.GetDirtyColumnStatistics(); + _readSnapshot is null ? Service.GetDirtyColumnStatistics() : Array.Empty(); public void ApplyCommittedAdvisoryStatisticsSnapshot( IReadOnlyCollection tableStatistics, IReadOnlyCollection columnStatistics, bool markDirty = false) => - _service.ApplyCommittedAdvisoryStatisticsSnapshot(tableStatistics, columnStatistics, markDirty); + Service.ApplyCommittedAdvisoryStatisticsSnapshot(tableStatistics, columnStatistics, markDirty); public void ApplyCommittedTableRowCountDeltas(IReadOnlyCollection> rowCountDeltas) => - _service.ApplyCommittedTableRowCountDeltas(rowCountDeltas); + Service.ApplyCommittedTableRowCountDeltas(rowCountDeltas); public void ApplyCommittedTableMetadataSnapshot(IReadOnlyCollection> nextRowIds) => - _service.ApplyCommittedTableMetadataSnapshot(nextRowIds); + Service.ApplyCommittedTableMetadataSnapshot(nextRowIds); public bool TryGetFreshColumnStatistics(string tableName, string columnName, out ColumnStatistics stats) => - _service.TryGetFreshColumnStatistics(tableName, columnName, out stats); + _readSnapshot is null ? Service.TryGetFreshColumnStatistics(tableName, columnName, out stats) : _readSnapshot.TryGetFreshColumnStatistics(tableName, columnName, out stats); public bool TryGetFreshColumnDistributionStatistics(string tableName, string columnName, out ColumnDistributionStatistics stats) => - _service.TryGetFreshColumnDistributionStatistics(tableName, columnName, out stats); + _readSnapshot is null ? Service.TryGetFreshColumnDistributionStatistics(tableName, columnName, out stats) : _readSnapshot.TryGetFreshColumnDistributionStatistics(tableName, columnName, out stats); public bool TryGetFreshIndexPrefixStatistics(string indexName, out IndexPrefixStatistics stats) => - _service.TryGetFreshIndexPrefixStatistics(indexName, out stats); + _readSnapshot is null ? Service.TryGetFreshIndexPrefixStatistics(indexName, out stats) : _readSnapshot.TryGetFreshIndexPrefixStatistics(indexName, out stats); internal IReadOnlyCollection GetColumnDistributionStatistics() => - _service.GetColumnDistributionStatistics(); + _readSnapshot is null ? Service.GetColumnDistributionStatistics() : _readSnapshot.GetColumnDistributionStatistics(); internal IReadOnlyCollection GetIndexPrefixStatistics() => - _service.GetIndexPrefixStatistics(); + _readSnapshot is null ? Service.GetIndexPrefixStatistics() : _readSnapshot.GetIndexPrefixStatistics(); public bool TryGetTableRowCount(string tableName, out long rowCount) => - _service.TryGetEstimatedTableRowCount(tableName, out rowCount); + _readSnapshot is null ? Service.TryGetEstimatedTableRowCount(tableName, out rowCount) : _readSnapshot.TryGetEstimatedTableRowCount(tableName, out rowCount); public bool TryGetEstimatedTableRowCount(string tableName, out long rowCount) => - _service.TryGetEstimatedTableRowCount(tableName, out rowCount); + _readSnapshot is null ? Service.TryGetEstimatedTableRowCount(tableName, out rowCount) : _readSnapshot.TryGetEstimatedTableRowCount(tableName, out rowCount); public bool TryGetExactTableRowCount(string tableName, out long rowCount) => - _service.TryGetExactTableRowCount(tableName, out rowCount); + _readSnapshot is null ? Service.TryGetExactTableRowCount(tableName, out rowCount) : _readSnapshot.TryGetExactTableRowCount(tableName, out rowCount); public ValueTask GetExactTableRowCountAsync(string tableName, CancellationToken ct = default) => - _service.GetExactTableRowCountAsync(tableName, ct); + _readSnapshot is null ? Service.GetExactTableRowCountAsync(tableName, ct) : _readSnapshot.GetExactTableRowCountAsync(tableName, _readPager!, ct); - public bool HasDirtyAdvisoryStatistics => _service.HasDirtyAdvisoryStatistics; - public bool HasAdvisoryCatalogContentChanges => _service.HasAdvisoryCatalogContentChanges; + public bool HasDirtyAdvisoryStatistics => _readSnapshot is null && Service.HasDirtyAdvisoryStatistics; + public bool HasAdvisoryCatalogContentChanges => _readSnapshot is null && Service.HasAdvisoryCatalogContentChanges; - public uint GetTableRootPage(string tableName) => _service.GetTableRootPage(tableName); + public uint GetTableRootPage(string tableName) => _readSnapshot is null ? Service.GetTableRootPage(tableName) : _readSnapshot.GetTableRootPage(tableName); - public IReadOnlyCollection GetTableNames() => _service.GetTableNames(); + public IReadOnlyCollection GetTableNames() => _readSnapshot is null ? Service.GetTableNames() : _readSnapshot.GetTableNames(); public ValueTask PersistRootPageChangesAsync(string tableName, CancellationToken ct = default) => - _service.PersistRootPageChangesAsync(tableName, ct); + Service.PersistRootPageChangesAsync(tableName, ct); public ValueTask PersistAllRootPageChangesAsync(CancellationToken ct = default) => - _service.PersistAllRootPageChangesAsync(ct); + Service.PersistAllRootPageChangesAsync(ct); public ValueTask PersistAllRootPageChangesAndDetectChangesAsync(CancellationToken ct = default) => - _service.PersistAllRootPageChangesAndDetectChangesAsync(ct); + Service.PersistAllRootPageChangesAndDetectChangesAsync(ct); public ValueTask ReloadAsync(CancellationToken ct = default) => - _service.ReloadAsync(ct); + Service.ReloadAsync(ct); public ValueTask CreateTableAsync(TableSchema schema, CancellationToken ct = default) => - _service.CreateTableAsync(schema, ct); + Service.CreateTableAsync(schema, ct); public ValueTask CreateTableExactAsync(TableSchema schema, CancellationToken ct = default) => - _service.CreateTableExactAsync(schema, ct); + Service.CreateTableExactAsync(schema, ct); public ValueTask DropTableAsync(string tableName, CancellationToken ct = default) => - _service.DropTableAsync(tableName, ct); + Service.DropTableAsync(tableName, ct); public ValueTask UpdateTableSchemaAsync(string oldTableName, TableSchema newSchema, CancellationToken ct = default) => - _service.UpdateTableSchemaAsync(oldTableName, newSchema, ct); + Service.UpdateTableSchemaAsync(oldTableName, newSchema, ct); internal ValueTask ApplyTableSchemaIdentitiesAsync( string tableName, TableSchema identitySource, CancellationToken ct = default) => - _service.ApplyTableSchemaIdentitiesAsync(tableName, identitySource, ct); + Service.ApplyTableSchemaIdentitiesAsync(tableName, identitySource, ct); public ValueTask ReplaceTableStorageAsync( string tableName, @@ -185,12 +229,12 @@ public ValueTask ReplaceTableStorageAsync( uint replacementRootPage, long exactRowCount, CancellationToken ct = default) => - _service.ReplaceTableStorageAsync(tableName, newSchema, replacementRootPage, exactRowCount, ct); + Service.ReplaceTableStorageAsync(tableName, newSchema, replacementRootPage, exactRowCount, ct); internal ValueTask CreateDetachedIndexStoreAsync( IndexSchema schema, CancellationToken ct = default) => - _service.CreateDetachedIndexStoreAsync(schema, ct); + Service.CreateDetachedIndexStoreAsync(schema, ct); internal ValueTask ReplaceTableAndIndexStorageAsync( string tableName, @@ -199,7 +243,7 @@ internal ValueTask ReplaceTableAndIndexStorageA long exactRowCount, IReadOnlyDictionary replacementIndexes, CancellationToken ct = default) => - _service.ReplaceTableAndIndexStorageAsync( + Service.ReplaceTableAndIndexStorageAsync( tableName, newSchema, replacementTableRootPage, @@ -208,99 +252,99 @@ internal ValueTask ReplaceTableAndIndexStorageA ct); public ValueTask SetTableRowCountAsync(string tableName, long rowCount, CancellationToken ct = default) => - _service.SetTableRowCountAsync(tableName, rowCount, ct); + Service.SetTableRowCountAsync(tableName, rowCount, ct); public ValueTask AdjustTableRowCountAsync(string tableName, long delta, CancellationToken ct = default) => - _service.AdjustTableRowCountAsync(tableName, delta, ct); + Service.AdjustTableRowCountAsync(tableName, delta, ct); public ValueTask AdjustTableRowCountKnownExactAsync(string tableName, long delta, CancellationToken ct = default) => - _service.AdjustTableRowCountKnownExactAsync(tableName, delta, ct); + Service.AdjustTableRowCountKnownExactAsync(tableName, delta, ct); public ValueTask PersistDirtyTableStatisticsAsync(CancellationToken ct = default) => - _service.PersistDirtyTableStatisticsAsync(ct); + Service.PersistDirtyTableStatisticsAsync(ct); public ValueTask PersistDirtyAdvisoryStatisticsAsync(CancellationToken ct = default) => - _service.PersistDirtyAdvisoryStatisticsAsync(ct); + Service.PersistDirtyAdvisoryStatisticsAsync(ct); public ValueTask ReplaceColumnStatisticsAsync( string tableName, IReadOnlyList columnStatistics, CancellationToken ct = default) => - _service.ReplaceColumnStatisticsAsync(tableName, columnStatistics, ct); + Service.ReplaceColumnStatisticsAsync(tableName, columnStatistics, ct); public ValueTask ReplaceColumnDistributionStatisticsAsync( string tableName, IReadOnlyList columnStatistics, CancellationToken ct = default) => - _service.ReplaceColumnDistributionStatisticsAsync(tableName, columnStatistics, ct); + Service.ReplaceColumnDistributionStatisticsAsync(tableName, columnStatistics, ct); public ValueTask ReplaceIndexPrefixStatisticsForTableAsync( string tableName, IReadOnlyList indexStatistics, CancellationToken ct = default) => - _service.ReplaceIndexPrefixStatisticsForTableAsync(tableName, indexStatistics, ct); + Service.ReplaceIndexPrefixStatisticsForTableAsync(tableName, indexStatistics, ct); public ValueTask MarkTableColumnStatisticsStaleAsync(string tableName, CancellationToken ct = default) => - _service.MarkTableColumnStatisticsStaleAsync(tableName, ct); + Service.MarkTableColumnStatisticsStaleAsync(tableName, ct); - public BTree GetTableTree(string tableName) => _service.GetTableTree(tableName); + public BTree GetTableTree(string tableName) => _readSnapshot is null ? Service.GetTableTree(tableName) : _readSnapshot.GetTableTree(tableName, _readPager!); - public BTree GetTableTree(string tableName, Pager pager) => _service.GetTableTree(tableName, pager); + public BTree GetTableTree(string tableName, Pager pager) => _readSnapshot is null ? Service.GetTableTree(tableName, pager) : _readSnapshot.GetTableTree(tableName, pager); - public IndexSchema? GetIndex(string indexName) => _service.GetIndex(indexName); + public IndexSchema? GetIndex(string indexName) => _readSnapshot is null ? Service.GetIndex(indexName) : _readSnapshot.GetIndex(indexName); - public IReadOnlyCollection GetIndexes() => _service.GetIndexes(); + public IReadOnlyCollection GetIndexes() => _readSnapshot is null ? Service.GetIndexes() : _readSnapshot.GetIndexes(); public IReadOnlyList GetIndexesForTable(string tableName) => - _service.GetIndexesForTable(tableName); + _readSnapshot is null ? Service.GetIndexesForTable(tableName) : _readSnapshot.GetIndexesForTable(tableName); public IReadOnlyList GetSqlIndexesForTable(string tableName) => - _service.GetSqlIndexesForTable(tableName); + _readSnapshot is null ? Service.GetSqlIndexesForTable(tableName) : _readSnapshot.GetSqlIndexesForTable(tableName); - public IIndexStore GetIndexStore(string indexName) => _service.GetIndexStore(indexName); + public IIndexStore GetIndexStore(string indexName) => _readSnapshot is null ? Service.GetIndexStore(indexName) : _readSnapshot.GetIndexStore(indexName, _readPager!); - public IIndexStore GetIndexStore(string indexName, Pager pager) => _service.GetIndexStore(indexName, pager); + public IIndexStore GetIndexStore(string indexName, Pager pager) => _readSnapshot is null ? Service.GetIndexStore(indexName, pager) : _readSnapshot.GetIndexStore(indexName, pager); public ValueTask CreateIndexAsync(IndexSchema schema, CancellationToken ct = default) => - _service.CreateIndexAsync(schema, ct); + Service.CreateIndexAsync(schema, ct); public ValueTask UpdateIndexSchemaAsync(string oldIndexName, IndexSchema newSchema, CancellationToken ct = default) => - _service.UpdateIndexSchemaAsync(oldIndexName, newSchema, ct); + Service.UpdateIndexSchemaAsync(oldIndexName, newSchema, ct); public ValueTask DropIndexAsync(string indexName, CancellationToken ct = default) => - _service.DropIndexAsync(indexName, ct); + Service.DropIndexAsync(indexName, ct); public ValueTask DropIndexAllowCorruptReclaimAsync(string indexName, CancellationToken ct = default) => - _service.DropIndexAllowCorruptReclaimAsync(indexName, ct); + Service.DropIndexAllowCorruptReclaimAsync(indexName, ct); public ValueTask DropForeignKeyOwnedIndexAsync(string indexName, CancellationToken ct = default) => - _service.DropForeignKeyOwnedIndexAsync(indexName, ct); + Service.DropForeignKeyOwnedIndexAsync(indexName, ct); public ValueTask DropConstraintOwnedIndexAsync(string indexName, CancellationToken ct = default) => - _service.DropConstraintOwnedIndexAsync(indexName, ct); + Service.DropConstraintOwnedIndexAsync(indexName, ct); - public string? GetViewSql(string viewName) => _service.GetViewSql(viewName); + public string? GetViewSql(string viewName) => _readSnapshot is null ? Service.GetViewSql(viewName) : _readSnapshot.GetViewSql(viewName); - public IReadOnlyCollection GetViewNames() => _service.GetViewNames(); + public IReadOnlyCollection GetViewNames() => _readSnapshot is null ? Service.GetViewNames() : _readSnapshot.GetViewNames(); - public bool IsView(string name) => _service.IsView(name); + public bool IsView(string name) => _readSnapshot is null ? Service.IsView(name) : _readSnapshot.IsView(name); public ValueTask CreateViewAsync(string viewName, string sql, CancellationToken ct = default) => - _service.CreateViewAsync(viewName, sql, ct); + Service.CreateViewAsync(viewName, sql, ct); public ValueTask DropViewAsync(string viewName, CancellationToken ct = default) => - _service.DropViewAsync(viewName, ct); + Service.DropViewAsync(viewName, ct); - public TriggerSchema? GetTrigger(string triggerName) => _service.GetTrigger(triggerName); + public TriggerSchema? GetTrigger(string triggerName) => _readSnapshot is null ? Service.GetTrigger(triggerName) : _readSnapshot.GetTrigger(triggerName); - public IReadOnlyCollection GetTriggers() => _service.GetTriggers(); + public IReadOnlyCollection GetTriggers() => _readSnapshot is null ? Service.GetTriggers() : _readSnapshot.GetTriggers(); public IReadOnlyList GetTriggersForTable(string tableName) => - _service.GetTriggersForTable(tableName); + _readSnapshot is null ? Service.GetTriggersForTable(tableName) : _readSnapshot.GetTriggersForTable(tableName); public ValueTask CreateTriggerAsync(TriggerSchema schema, CancellationToken ct = default) => - _service.CreateTriggerAsync(schema, ct); + Service.CreateTriggerAsync(schema, ct); public ValueTask DropTriggerAsync(string triggerName, CancellationToken ct = default) => - _service.DropTriggerAsync(triggerName, ct); + Service.DropTriggerAsync(triggerName, ct); } diff --git a/src/CSharpDB.Storage/Checkpointing/CheckpointCoordinator.cs b/src/CSharpDB.Storage/Checkpointing/CheckpointCoordinator.cs index 77602304..05539899 100644 --- a/src/CSharpDB.Storage/Checkpointing/CheckpointCoordinator.cs +++ b/src/CSharpDB.Storage/Checkpointing/CheckpointCoordinator.cs @@ -17,6 +17,7 @@ internal sealed class CheckpointCoordinator : IDisposable private long _minimumRetainedWalOffset = long.MaxValue; private Task? _backgroundCheckpointTask; private bool _backgroundCheckpointStartsStopped; + private bool _backgroundCheckpointRestartRequested; public int ActiveReaderCount => Volatile.Read(ref _activeReaderCount); public bool HasPendingCheckpointRequest => Volatile.Read(ref _deferredCheckpointRequested) != 0; @@ -156,10 +157,9 @@ public bool ShouldCheckpoint( int legacyThreshold, long estimatedWalBytes) { - bool readersRequireWalRetention = TryGetMinimumRetainedWalOffset(out _); var context = new PagerCheckpointContext( committedFrameCount, - readersRequireWalRetention ? ActiveReaderCount : 0, + ActiveReaderCount, estimatedWalBytes); bool shouldCheckpoint = policy is FrameCountCheckpointPolicy @@ -236,12 +236,35 @@ public bool TryGetMinimumRetainedWalOffset(out long walOffset) return walOffset != long.MaxValue; } - public async ValueTask RunCheckpointAsync( + // Called only under RunCheckpointAsync's admission lock, using the WAL's + // frozen remaining targets. A map miss reads the base file, so copying that + // page would replace a version still visible to this snapshot. + internal bool CanCopyCheckpointPages(ReadOnlySpan> pages) + { + foreach (WalSnapshot snapshot in _activeSnapshots.Keys) + { + foreach (var page in pages) + { + if (!snapshot.TryGet(page.Key, out _)) + return false; + } + } + return true; + } + + public ValueTask RunCheckpointAsync( int committedFrameCount, Func checkpointAction, CancellationToken ct = default) + => RunCheckpointAsync(committedFrameCount, hasPendingCheckpoint: false, checkpointAction, ct); + + internal async ValueTask RunCheckpointAsync( + int committedFrameCount, + bool hasPendingCheckpoint, + Func checkpointAction, + CancellationToken ct = default) { - if (committedFrameCount == 0) + if (committedFrameCount == 0 && !hasPendingCheckpoint) return; await _checkpointLock.WaitAsync(ct); @@ -263,18 +286,71 @@ public bool TryStartBackgroundCheckpoint(Func chec lock (_backgroundCheckpointGate) { if (_backgroundCheckpointStartsStopped || - !HasPendingCheckpointRequest || - _backgroundCheckpointTask is { IsCompleted: false }) + !HasPendingCheckpointRequest) { return false; } - _backgroundCheckpointTask = Task.Run( - async () => await checkpointAction(CancellationToken.None)); + if (_backgroundCheckpointTask is { IsCompleted: false }) + { + // The last reader may drain after a blocked copy releases its + // admission lock but before its background action returns. + if (ActiveReaderCount == 0) + _backgroundCheckpointRestartRequested = true; + return false; + } + + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _backgroundCheckpointRestartRequested = false; + _backgroundCheckpointTask = completion.Task; + _ = Task.Run(() => RunBackgroundCheckpointAsync(checkpointAction, completion)); return true; } } + private async Task RunBackgroundCheckpointAsync( + Func checkpointAction, + TaskCompletionSource completion) + { + try + { + while (true) + { + await checkpointAction(CancellationToken.None).ConfigureAwait(false); + lock (_backgroundCheckpointGate) + { + bool restart = _backgroundCheckpointRestartRequested && + !_backgroundCheckpointStartsStopped && + HasPendingCheckpointRequest && ActiveReaderCount == 0; + _backgroundCheckpointRestartRequested = false; + if (restart) + continue; + + // Complete under the scheduling lock: a late wake either + // queued a retry above or sees a completed lifecycle. + completion.TrySetResult(); + return; + } + } + } + catch (OperationCanceledException ex) + { + lock (_backgroundCheckpointGate) + { + _backgroundCheckpointRestartRequested = false; + completion.TrySetCanceled(ex.CancellationToken); + } + } + catch (Exception ex) + { + lock (_backgroundCheckpointGate) + { + _backgroundCheckpointRestartRequested = false; + completion.TrySetException(ex); + } + } + } + public async ValueTask WaitForBackgroundCheckpointAsync(CancellationToken ct = default) { Task? backgroundCheckpointTask; @@ -348,6 +424,7 @@ public async ValueTask StopAndWaitForBackgroundCheckpointAsync() lock (_backgroundCheckpointGate) { _backgroundCheckpointStartsStopped = true; + _backgroundCheckpointRestartRequested = false; backgroundCheckpointTask = _backgroundCheckpointTask; } diff --git a/src/CSharpDB.Storage/Paging/MemoryMappedPageReadProvider.cs b/src/CSharpDB.Storage/Paging/MemoryMappedPageReadProvider.cs index c0789dcb..949e7fa9 100644 --- a/src/CSharpDB.Storage/Paging/MemoryMappedPageReadProvider.cs +++ b/src/CSharpDB.Storage/Paging/MemoryMappedPageReadProvider.cs @@ -13,21 +13,34 @@ internal sealed class MemoryMappedPageReadProvider : IPageReadProvider, IDisposa { private readonly FileStorageDevice _device; private readonly StorageDevicePageReadProvider _fallback; + private readonly bool _copyOnRead; + private readonly object _mappingGate = new(); private MemoryMappedFile? _mappedFile; private MemoryMappedViewMemoryManager? _mappedMemory; private long _mappedLength; - public MemoryMappedPageReadProvider(FileStorageDevice device) + public MemoryMappedPageReadProvider(FileStorageDevice device, bool copyOnRead = false) { ArgumentNullException.ThrowIfNull(device); _device = device; _fallback = new StorageDevicePageReadProvider(device); + _copyOnRead = copyOnRead; RefreshMapping(); } public ValueTask ReadPageAsync(uint pageId, CancellationToken ct = default) { + if (_copyOnRead) + { + // Shared-pager callers can retain a page while checkpointing remaps + // this provider. A read-only copy survives that remap and makes later + // mutable cache access copy again instead of changing a reader's data. + return TryCopyMappedPage(pageId, out var copy) + ? ValueTask.FromResult(PageReadBuffer.FromReadOnlyMemory(copy)) + : _fallback.ReadPageAsync(pageId, ct); + } + long offset = (long)pageId * PageConstants.PageSize; if (_mappedMemory is not null && offset >= 0 && offset + PageConstants.PageSize <= _mappedLength) { @@ -40,21 +53,36 @@ public ValueTask ReadPageAsync(uint pageId, CancellationToken ct return _fallback.ReadPageAsync(pageId, ct); } - public async ValueTask ReadOwnedPageAsync(uint pageId, CancellationToken ct = default) + public ValueTask ReadOwnedPageAsync(uint pageId, CancellationToken ct = default) + => TryCopyMappedPage(pageId, out var copy) + ? ValueTask.FromResult(copy) + : _fallback.ReadOwnedPageAsync(pageId, ct); + + private bool TryCopyMappedPage(uint pageId, out byte[] copy) { - long offset = (long)pageId * PageConstants.PageSize; - if (_mappedMemory is not null && offset >= 0 && offset + PageConstants.PageSize <= _mappedLength) + lock (_mappingGate) { - byte[] buffer = GC.AllocateUninitializedArray(PageConstants.PageSize); - _mappedMemory.Memory.Span.Slice((int)offset, PageConstants.PageSize).CopyTo(buffer); - _device.RecordMemoryMappedPageExposure(PageConstants.PageSize); - return buffer; + long offset = (long)pageId * PageConstants.PageSize; + if (_mappedMemory is not null && offset >= 0 && offset + PageConstants.PageSize <= _mappedLength) + { + copy = GC.AllocateUninitializedArray(PageConstants.PageSize); + _mappedMemory.Memory.Span.Slice((int)offset, PageConstants.PageSize).CopyTo(copy); + _device.RecordMemoryMappedPageExposure(PageConstants.PageSize); + return true; + } + + copy = null!; + return false; } - - return await _fallback.ReadOwnedPageAsync(pageId, ct); } internal void RefreshMapping() + { + lock (_mappingGate) + RefreshMappingCore(); + } + + private void RefreshMappingCore() { long mappedLength = _device.Length; bool shouldMap = mappedLength > 0 && mappedLength <= int.MaxValue; @@ -81,7 +109,8 @@ internal void RefreshMapping() public void Dispose() { - ResetMapping(); + lock (_mappingGate) + ResetMapping(); } private void ResetMapping() diff --git a/src/CSharpDB.Storage/Paging/PageBufferManager.cs b/src/CSharpDB.Storage/Paging/PageBufferManager.cs index 6eb9d735..71ca7881 100644 --- a/src/CSharpDB.Storage/Paging/PageBufferManager.cs +++ b/src/CSharpDB.Storage/Paging/PageBufferManager.cs @@ -526,6 +526,30 @@ public ValueTask MarkDirtyAsync( return EnsurePageInCacheAndPinAsync(pageId, getPageAsync, ct); } + internal void MarkDirty(uint pageId, byte[] page) + { + if (_isSnapshotReader) + throw new InvalidOperationException("Cannot modify pages on a read-only snapshot pager."); + + lock (_stateGate) + { + _dirtyPages.Add(pageId); + _readOnlyPages.Remove(pageId); + if (TryGetCachedEntryLocked(pageId, out var cached, out _) != CachedPageKind.Owned || + !ReferenceEquals(cached, page)) + { + SetSharedCacheLocked(pageId, page); + } + + // Replacing an entry can report eviction of its old buffer. The + // newly registered buffer is now authoritative for this page id. + if (_useEvictionDrivenDirtyBufferTracking) + _dirtyBuffers.Remove(pageId); + else + PinDirtyBufferLocked(pageId, page); + } + } + public void AddDirty(uint pageId) { lock (_stateGate) diff --git a/src/CSharpDB.Storage/Paging/Pager.cs b/src/CSharpDB.Storage/Paging/Pager.cs index 76c477fd..ed1a0461 100644 --- a/src/CSharpDB.Storage/Paging/Pager.cs +++ b/src/CSharpDB.Storage/Paging/Pager.cs @@ -68,6 +68,10 @@ private readonly Queue? private readonly object _legacyLogicalWriteGate = new(); private readonly HashSet _legacyLogicalWriteKeys = []; private int _disposeRequested; + private int _backgroundCheckpointWriterWaitCount; + private int _lastAppendedCommitPageCount; + private int _pendingLegacyCatalogCommits; + private int _legacyCatalogCaptureFailed; private bool _runtimeCheckpointActive; private StorageCheckpointOriginRaw _runtimeCheckpointOrigin; private long? _runtimeCheckpointCompletedPageCount; @@ -146,7 +150,7 @@ private readonly Queue? private long _hashedIndexAppendNotApplicableCount; private long _hashedIndexDeferredAppendCount; private long _hashedIndexDeferredFlushCount; - private readonly ConcurrentDictionary _btreeResourceDiagnostics = new(); + private readonly ConcurrentDictionary? _btreeResourceDiagnostics; private readonly object _explicitCommitStateGate = new(); private readonly Dictionary _explicitCachedPageVersions = new(); private uint _scheduledExplicitPageCount; @@ -376,6 +380,15 @@ private bool TryCaptureRuntimeDiagnosticsCandidate( GetCurrentTransaction() is not null || _transactions?.InTransaction == true; + // The caller holds its writer/catalog gates. Legacy shared metadata is + // committed only after every completion (including interceptors) finishes. + // Also reject a snapshot acquired before a just-completed publication. + internal bool CanCaptureCommittedCatalog(WalSnapshot snapshot) => + !IsWriteTransactionActive && + Volatile.Read(ref _pendingLegacyCatalogCommits) == 0 && + Volatile.Read(ref _legacyCatalogCaptureFailed) == 0 && + _walIndex.CommitCounter == snapshot.CommitCounter; + internal bool RequiresLogicalWriteConflictTracking => GetCurrentTransaction() is not null || _transactions?.HasActiveExplicitTransactions == true; @@ -546,7 +559,7 @@ internal void ResetCommitPathDiagnostics() Interlocked.Exchange(ref _hashedIndexAppendNotApplicableCount, 0); Interlocked.Exchange(ref _hashedIndexDeferredAppendCount, 0); Interlocked.Exchange(ref _hashedIndexDeferredFlushCount, 0); - _btreeResourceDiagnostics.Clear(); + _btreeResourceDiagnostics?.Clear(); } // Configurable behavior @@ -585,6 +598,7 @@ private Pager( IStorageRuntimeDiagnosticsObserver? runtimeDiagnosticsObserver = null) { _device = device; + _btreeResourceDiagnostics = new(); _options = options ?? new PagerOptions(); ValidateOptions(_options); _storageIoRuntimeDiagnostics = runtimeDiagnosticsObserver is null @@ -683,15 +697,8 @@ private Pager( _isSnapshotReader, _interceptor, _storageIoRuntimeDiagnostics); - _allocator = new PageAllocator( - _buffers, - () => FreelistHead, - value => FreelistHead = value, - () => PageCount, - value => PageCount = value, - GetPageAsync, - MarkDirtyAsync, - () => _isSnapshotReader); + // Readers reject allocation/free without capturing unused writer state. + _allocator = ReadOnlyPageAllocator.Instance; CheckpointPolicy = _options.CheckpointPolicy; if (_options.CheckpointPolicy is FrameCountCheckpointPolicy framePolicy) @@ -737,7 +744,7 @@ _pageReads is MemoryMappedPageReadProvider || _speculativePageReads is MemoryMappedPageReadProvider; var pageReads = needsDedicatedProviders - ? CreatePageReadProvider(_device, _options) + ? CreatePageReadProvider(_device, _options, copyMappedPages: false) : _pageReads; var speculativePageReads = needsDedicatedProviders @@ -762,6 +769,24 @@ _pageReads is MemoryMappedPageReadProvider || return reader; } + internal async ValueTask LoadSnapshotHeaderAsync(CancellationToken ct = default) + { + if (!_isSnapshotReader) + throw new InvalidOperationException("Snapshot header loading requires a snapshot reader."); + var page = await GetPageReadAsync(0, ct); + ReadFileHeaderFrom(page.Memory.Span); + } + + internal void ApplySnapshotHeader(uint pageCount, uint schemaRootPage, uint freelistHead, uint changeCounter) + { + if (!_isSnapshotReader) + throw new InvalidOperationException("Snapshot header restoration requires a snapshot reader."); + PageCount = pageCount; + SchemaRootPage = schemaRootPage; + FreelistHead = freelistHead; + ChangeCounter = changeCounter; + } + /// /// Initialize a brand new database file. /// @@ -971,6 +996,26 @@ public ValueTask MarkDirtyAsync(uint pageId, CancellationToken ct = default) ct); } + // Structural edits can retain a clean page across reads or allocations that + // evict it. Register the actual edited buffer, rather than reloading its id. + internal ValueTask MarkDirtyAsync(uint pageId, byte[] page, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(page); + if (page.Length != PageConstants.PageSize) + throw new ArgumentException("A dirty page must contain exactly one page of data.", nameof(page)); + + if (GetCurrentTransaction() is { } tx) + { + if (!tx.ModifiedPages.TryGetValue(pageId, out var owned) || !ReferenceEquals(owned, page)) + throw new InvalidOperationException("The dirty buffer must belong to the current write transaction."); + tx.DirtyPages.Add(pageId); + return ValueTask.CompletedTask; + } + + _buffers.MarkDirty(pageId, page); + return ValueTask.CompletedTask; + } + /// /// Allocate a new page. Tries the freelist first, then extends the page count. /// In WAL mode, the DB file is NOT extended here — that happens during checkpoint. @@ -1003,7 +1048,7 @@ public async ValueTask BeginTransactionAsync(CancellationToken ct = default) if (GetCurrentTransaction() != null || _transactions?.InTransaction == true) throw new CSharpDbException(ErrorCode.Unknown, "Nested transactions are not supported."); - await WaitForBackgroundCheckpointAsync(ct); + await WaitForBackgroundCheckpointForWriterAsync(ct); if (!_wal.IsOpen) await _wal.OpenAsync(PageCount, ct); await _transactions!.BeginAsync(_wal, _options.WriterLockTimeout, ct); @@ -1091,7 +1136,7 @@ internal async ValueTask BeginWriteTransactionAsync(Cance if (_transactions is not null) await _transactions.WaitForSchemaBeginAsync(ct); - await WaitForBackgroundCheckpointAsync(ct); + await WaitForBackgroundCheckpointForWriterAsync(ct); if (!_wal.IsOpen) await _wal.OpenAsync(PageCount, ct); @@ -1191,7 +1236,9 @@ internal void RecordBTreeLeafSplit(string? resourceName, bool rightEdge) if (string.IsNullOrEmpty(resourceName)) return; - BTreeResourceDiagnosticsCounter diagnostics = GetOrAddBTreeResourceDiagnostics(resourceName); + BTreeResourceDiagnosticsCounter? diagnostics = GetOrAddBTreeResourceDiagnostics(resourceName); + if (diagnostics is null) + return; Interlocked.Increment(ref diagnostics.LeafSplitCount); if (rightEdge) Interlocked.Increment(ref diagnostics.RightEdgeLeafSplitCount); @@ -1209,7 +1256,9 @@ internal void RecordBTreeInteriorInsert(string? resourceName, bool rightEdge) if (string.IsNullOrEmpty(resourceName)) return; - BTreeResourceDiagnosticsCounter diagnostics = GetOrAddBTreeResourceDiagnostics(resourceName); + BTreeResourceDiagnosticsCounter? diagnostics = GetOrAddBTreeResourceDiagnostics(resourceName); + if (diagnostics is null) + return; Interlocked.Increment(ref diagnostics.InteriorInsertCount); if (rightEdge) Interlocked.Increment(ref diagnostics.RightEdgeInteriorInsertCount); @@ -1227,7 +1276,9 @@ internal void RecordBTreeInteriorSplit(string? resourceName, bool rightEdge) if (string.IsNullOrEmpty(resourceName)) return; - BTreeResourceDiagnosticsCounter diagnostics = GetOrAddBTreeResourceDiagnostics(resourceName); + BTreeResourceDiagnosticsCounter? diagnostics = GetOrAddBTreeResourceDiagnostics(resourceName); + if (diagnostics is null) + return; Interlocked.Increment(ref diagnostics.InteriorSplitCount); if (rightEdge) Interlocked.Increment(ref diagnostics.RightEdgeInteriorSplitCount); @@ -1242,7 +1293,9 @@ internal void RecordBTreeRootSplit(string? resourceName) if (string.IsNullOrEmpty(resourceName)) return; - BTreeResourceDiagnosticsCounter diagnostics = GetOrAddBTreeResourceDiagnostics(resourceName); + BTreeResourceDiagnosticsCounter? diagnostics = GetOrAddBTreeResourceDiagnostics(resourceName); + if (diagnostics is null) + return; Interlocked.Increment(ref diagnostics.RootSplitCount); } @@ -1705,6 +1758,7 @@ private static bool HaveExplicitWritePageIdsChanged(uint[] priorWritePageIds, Ha private async ValueTask BeginLegacyCommitAsync(CancellationToken ct) { + Interlocked.Increment(ref _pendingLegacyCatalogCommits); try { await EnsureBaseHeaderUsesCurrentFormatAsync(ct); @@ -1721,12 +1775,28 @@ private async ValueTask BeginLegacyCommitAsync(CancellationTo } catch { - await RollbackAsync(CancellationToken.None); - await ResetPagerStateFromCommittedStorageAsync(CancellationToken.None); + try + { + await RollbackAsync(CancellationToken.None); + await ResetPagerStateFromCommittedStorageAsync(CancellationToken.None); + } + finally + { + CompleteLegacyCatalogCapture(succeeded: false); + } throw; } } + private void CompleteLegacyCatalogCapture(bool succeeded) + { + // Failed durable work can leave Engine metadata ahead until recovery. + // Preserve the conservative fallback even if recovery itself fails. + if (!succeeded) + Volatile.Write(ref _legacyCatalogCaptureFailed, 1); + Interlocked.Decrement(ref _pendingLegacyCatalogCommits); + } + private async ValueTask BeginLegacyCommitWithInterceptorAsync(int dirtyCount, CancellationToken ct) { await _interceptor.OnCommitStartAsync(dirtyCount, ct); @@ -1764,7 +1834,7 @@ private async ValueTask BeginLegacyCommitWithInterceptorAsync } WalCommitResult commitResult = await PrepareWalCommitAsync(ct); - RecordWalAppendDiagnostics(walAppendStartTicks); + RecordWalAppendDiagnostics(walAppendStartTicks, dirtyCount); _buffers.ClearDirty(); long transactionId = _transactions!.ReleaseWriterAfterCommitAppend(); if (CanBypassLegacyPendingCommitReservation(commitResult)) @@ -1829,7 +1899,7 @@ private async ValueTask BeginLegacyCommitFastAsync(int dirtyC ? await _wal.AppendFramesAndCommitAsync(frameBatch.AsMemory(0, frameCount), PageCount, ct) : await _wal.CommitAsync(PageCount, ct); - RecordWalAppendDiagnostics(walAppendStartTicks); + RecordWalAppendDiagnostics(walAppendStartTicks, dirtyCount); _buffers.ClearDirty(); long transactionId = _transactions!.ReleaseWriterAfterCommitAppend(); if (CanBypassLegacyPendingCommitReservation(commitResult)) @@ -1867,11 +1937,13 @@ private async Task CompleteLegacyCommitAsync( long transactionId, TransactionCoordinator.PendingCommitReservation pendingCommitReservation) { + bool succeeded = false; try { await commitResult.WaitAsync(); _transactions!.PublishPendingCommit(pendingCommitReservation); await FinalizeLegacyCommitAndCheckpointAsync(transactionId, CancellationToken.None); + succeeded = true; } catch { @@ -1879,6 +1951,10 @@ private async Task CompleteLegacyCommitAsync( await ResetPagerStateFromCommittedStorageAsync(CancellationToken.None); throw; } + finally + { + CompleteLegacyCatalogCapture(succeeded); + } } private Task CompleteLegacyCommitWithoutPendingReservationAsync(long transactionId) @@ -1886,15 +1962,21 @@ private Task CompleteLegacyCommitWithoutPendingReservationAsync(long transaction private async Task CompleteLegacyCommitWithoutPendingReservationCoreAsync(long transactionId) { + bool succeeded = false; try { await FinalizeLegacyCommitAndCheckpointAsync(transactionId, CancellationToken.None); + succeeded = true; } catch { await ResetPagerStateFromCommittedStorageAsync(CancellationToken.None); throw; } + finally + { + CompleteLegacyCatalogCapture(succeeded); + } } private async Task CompleteLegacyCommitWithInterceptorAsync( @@ -1919,7 +2001,19 @@ private async Task CompleteLegacyCommitWithInterceptorAsync( } finally { - await _interceptor.OnCommitEndAsync(dirtyCount, commitSucceeded, CancellationToken.None); + try + { + await _interceptor.OnCommitEndAsync(dirtyCount, commitSucceeded, CancellationToken.None); + } + catch + { + commitSucceeded = false; + throw; + } + finally + { + CompleteLegacyCatalogCapture(commitSucceeded); + } } } @@ -1945,7 +2039,19 @@ private async Task CompleteLegacyCommitWithoutPendingReservationWithInterceptorC } finally { - await _interceptor.OnCommitEndAsync(dirtyCount, commitSucceeded, CancellationToken.None); + try + { + await _interceptor.OnCommitEndAsync(dirtyCount, commitSucceeded, CancellationToken.None); + } + catch + { + commitSucceeded = false; + throw; + } + finally + { + CompleteLegacyCatalogCapture(commitSucceeded); + } } } @@ -2083,7 +2189,7 @@ private async ValueTask BeginExplicitCommitAsync(PagerTransac continue; } - throw CreateTerminalExplicitConflictException( + throw CreateTerminalExplicitConflictException(tx, $"Transaction conflict detected while committing page {conflictPageId}. The transaction must be retried."); } @@ -2162,7 +2268,7 @@ private async ValueTask BeginExplicitCommitAsync(PagerTransac walCommit = await _wal.AppendFramesAndCommitAsync(frameBatch, headerReservation.PageCount, ct); } - RecordWalAppendDiagnostics(walAppendStartTicks); + RecordWalAppendDiagnostics(walAppendStartTicks, dirtyCount); long pendingCommitReservationStartTicks = Stopwatch.GetTimestamp(); pendingCommitReservation = _transactions?.ReservePendingCommit( writePageIds, @@ -2714,6 +2820,14 @@ internal async ValueTask CheckpointAsync( await WaitForBackgroundCheckpointAsync(ct); + if (origin == StorageCheckpointOriginRaw.ForegroundAuto && + _checkpoints is { ActiveReaderCount: > 0 } && + (!_wal.IsCheckpointCopyComplete || _checkpoints.TryGetMinimumRetainedWalOffset(out _))) + { + _checkpoints.RequestDeferredCheckpoint(); + return; + } + await RunCheckpointWithInterceptorsAsync(origin, ct); if (!HasCheckpointWorkPending()) _checkpoints?.ClearDeferredCheckpointRequest(); @@ -2737,6 +2851,7 @@ private async ValueTask RunCheckpointWithInterceptorsAsync( { await _checkpoints.RunCheckpointAsync( frameCount, + _wal.HasPendingCheckpoint, async innerCt => { checkpointRan = true; @@ -2756,6 +2871,7 @@ await _checkpoints.RunCheckpointAsync( { await _checkpoints.RunCheckpointAsync( frameCount, + _wal.HasPendingCheckpoint, async innerCt => { checkpointRan = true; @@ -2774,6 +2890,7 @@ private async ValueTask RunBackgroundCheckpointStepWithInterceptorsAsync(Cancell if (_checkpoints is null) return; + long copiedPageBudget = 0; while (true) { if (!HasCheckpointWorkPending()) @@ -2786,12 +2903,40 @@ private async ValueTask RunBackgroundCheckpointStepWithInterceptorsAsync(Cancell if (!checkpointRan) return; + copiedPageBudget += _options.AutoCheckpointMaxPagesPerStep; if (!HasCheckpointWorkPending()) { _checkpoints.ClearDeferredCheckpointRequest(); return; } + int lastCommitPageCount = Volatile.Read(ref _lastAppendedCommitPageCount); + // Small commits keep the full-drain behavior: interleaving a whole + // slice with each cheap transaction adds more maintenance overhead. + bool writerWaiting = Volatile.Read(ref _backgroundCheckpointWriterWaitCount) != 0 && + lastCommitPageCount >= _options.AutoCheckpointMaxPagesPerStep; + // New commits retained after a completed copy belong to the next + // policy interval. Do not force a waiting writer to pay for tiny + // follow-up checkpoints below the configured threshold. + if (writerWaiting && !_wal.HasPendingCheckpoint && + !_checkpoints.ShouldCheckpoint( + CheckpointPolicy, + _walIndex.FrameCount, + CheckpointThreshold, + EstimateCommittedWalBytes(_walIndex.FrameCount))) + { + _checkpoints.ClearDeferredCheckpointRequest(); + return; + } + + // Cover the last commit's copy budget before yielding during an + // incomplete copy. While idle, drain all slices to completion. + if (writerWaiting && + copiedPageBudget >= Math.Max( + _options.AutoCheckpointMaxPagesPerStep, + lastCommitPageCount)) + return; + if (_checkpoints.TryGetMinimumRetainedWalOffset(out _) && _wal.IsCheckpointCopyComplete) { @@ -2818,6 +2963,7 @@ private async ValueTask RunBackgroundCheckpointStepAsync(CancellationToken { await _checkpoints.RunCheckpointAsync( frameCount, + _wal.HasPendingCheckpoint, async innerCt => { checkpointRan = await RunCheckpointStepCoreAsync( @@ -2840,6 +2986,7 @@ await _checkpoints.RunCheckpointAsync( { await _checkpoints.RunCheckpointAsync( frameCount, + _wal.HasPendingCheckpoint, async innerCt => { checkpointRan = await RunCheckpointStepCoreAsync( @@ -2869,6 +3016,9 @@ private async ValueTask RunCheckpointCoreAsync( bool wasCopyComplete = _wal.IsCheckpointCopyComplete; if (!wasCopyComplete) { + if (!await CanCopyCheckpointPagesAsync(ct)) + return; + ReportRuntimeCheckpointWorkStartedOrChanged( origin, StorageCheckpointPhaseRaw.Copying); @@ -2910,6 +3060,9 @@ private async ValueTask RunCheckpointStepCoreAsync( return false; } + if (!await CanCopyCheckpointPagesAsync(ct)) + return false; + ReportRuntimeCheckpointWorkStartedOrChanged( origin, StorageCheckpointPhaseRaw.Copying); @@ -2937,6 +3090,36 @@ private async ValueTask RunCheckpointStepCoreAsync( } } + private async ValueTask CanCopyCheckpointPagesAsync(CancellationToken ct) + { + // Both callers hold the coordinator lock, keeping reader admission and + // release serialized with the complete prepare/check/copy operation. + if (_checkpoints is null || _checkpoints.ActiveReaderCount == 0) + return true; + + try + { + if (_wal is IWalCheckpointCopyPlan copyPlan) + { + ReadOnlyMemory> pages = + await copyPlan.PrepareCheckpointCopyAsync(PageCount, ct); + if (_checkpoints.CanCopyCheckpointPages(pages.Span)) + return true; + } + } + catch (Exception ex) + { + CompleteRuntimeCheckpoint(ex); + _checkpoints.SetRuntimePhase(StorageCheckpointPhaseRaw.Faulted); + throw; + } + + _checkpoints.RequestDeferredCheckpoint(); + PublishRuntimeCheckpointChanged(StorageCheckpointPhaseRaw.Requested); + _checkpoints.SetRuntimePhaseUnlessFaulted(StorageCheckpointPhaseRaw.Requested); + return false; + } + private async ValueTask TryFinalizeCheckpointAsync( StorageCheckpointOriginRaw origin, CancellationToken ct) @@ -2966,10 +3149,19 @@ private async ValueTask TryFinalizeCheckpointAsync( PublishRuntimeCheckpointChanged(StorageCheckpointPhaseRaw.Requested); _checkpoints.SetRuntimePhaseUnlessFaulted(StorageCheckpointPhaseRaw.Requested); + IDisposable? commitLock = null; IDisposable? checkpointBarrier = null; try { + // Conflict resolution (including parent/split and freelist reads) holds + // the commit lock while using latest WAL offsets, even when its snapshot + // retains no frames. Keep those offsets stable through finalization and + // state refresh. Take commit before begin/writer, matching explicit commit + // admission; taking begin first would deadlock a resolving writer. + commitLock = _transactions is not null + ? await _transactions.AcquireCommitLockAsync(ct) + : null; checkpointBarrier = _transactions is not null ? await _transactions.AcquireCheckpointBarrierAsync(ct) : null; @@ -2977,6 +3169,7 @@ private async ValueTask TryFinalizeCheckpointAsync( int frameCount = _walIndex.FrameCount; await _checkpoints.RunCheckpointAsync( frameCount, + _wal.HasPendingCheckpoint, async innerCt => { if (_checkpoints.TryGetMinimumRetainedWalOffset(out _) || @@ -2987,6 +3180,17 @@ await _checkpoints.RunCheckpointAsync( return; } + // Legacy writers release the writer lock after append, before + // durable publication. Their pending batches still own physical + // offsets. With both admission barriers held, no new batch can + // start; defer until existing batches finish publishing. + if (_wal.HasPendingCommitWork) + { + _checkpoints.RequestDeferredCheckpoint(); + UpdateCheckpointRuntimePhaseAfterCopy(); + return; + } + ReportRuntimeCheckpointWorkStartedOrChanged( origin, StorageCheckpointPhaseRaw.Finalizing); @@ -3024,6 +3228,7 @@ await _checkpoints.RunCheckpointAsync( finally { checkpointBarrier?.Dispose(); + commitLock?.Dispose(); } } @@ -3354,7 +3559,15 @@ private async ValueTask RefreshStateAfterCheckpointCompletionAsync(CancellationT { _buffers.InvalidateCheckpointTransientReads(_options.PreserveOwnedPagesOnCheckpoint); RefreshPageReadProviderMapping(); - if (_device.Length >= PageConstants.PageSize) + // New commits can advance the header while an older checkpoint is + // copied. Keep allocator state on the latest committed page 0. + if (_walIndex.TryGetLatest(0, out long walOffset)) + { + var headerPage = GetWalHeaderPageBuffer(); + await _wal.ReadPageIntoAsync(walOffset, headerPage, ct); + ReadFileHeaderFrom(headerPage); + } + else if (_device.Length >= PageConstants.PageSize) await ReadFileHeaderAsync(ct); } @@ -3675,6 +3888,8 @@ private void ScheduleBackgroundCheckpointIfNeeded() _checkpoints is null || Volatile.Read(ref _disposeRequested) != 0 || _options.AutoCheckpointExecutionMode != AutoCheckpointExecutionMode.Background || + (_checkpoints.ActiveReaderCount > 0 && + (!_wal.IsCheckpointCopyComplete || _checkpoints.TryGetMinimumRetainedWalOffset(out _))) || GetCurrentTransaction() is not null || _transactions?.InTransaction == true || _wal.HasPendingCommitWork) @@ -3686,6 +3901,25 @@ _checkpoints is null || Interlocked.Increment(ref _backgroundCheckpointStartCount); } + private ValueTask WaitForBackgroundCheckpointForWriterAsync(CancellationToken ct) + { + ValueTask wait = WaitForBackgroundCheckpointAsync(ct); + return wait.IsCompletedSuccessfully ? wait : WaitForBackgroundCheckpointWriterCoreAsync(wait); + } + + private async ValueTask WaitForBackgroundCheckpointWriterCoreAsync(ValueTask wait) + { + Interlocked.Increment(ref _backgroundCheckpointWriterWaitCount); + try + { + await wait; + } + finally + { + Interlocked.Decrement(ref _backgroundCheckpointWriterWaitCount); + } + } + private ValueTask WaitForBackgroundCheckpointAsync(CancellationToken ct = default) { if (_isSnapshotReader || @@ -3729,20 +3963,22 @@ private void ValidateLogicalConflicts(PagerTransactionState tx) if (_transactions.HasLogicalConflict(tx.LogicalReadKeys, tx.StartVersion, out LogicalConflictKey conflictKey)) { - throw CreateTerminalExplicitConflictException( + throw CreateTerminalExplicitConflictException(tx, $"Transaction conflict detected while validating logical key {conflictKey}. The transaction must be retried."); } if (_transactions.HasLogicalRangeConflict(tx.LogicalReadRanges, tx.StartVersion, out conflictKey)) { - throw CreateTerminalExplicitConflictException( + throw CreateTerminalExplicitConflictException(tx, $"Transaction conflict detected while validating logical range containing {conflictKey}. The transaction must be retried."); } } private CSharpDbConflictException CreateTerminalExplicitConflictException( + PagerTransactionState tx, string message) { + tx.CommitRejectedBeforePublication = true; SaturatingIncrementRuntimeCounter(ref _terminalExplicitConflictCount); return new CSharpDbConflictException(message); } @@ -4760,8 +4996,9 @@ private void ClearLegacyLogicalWriteTracking() } } - private void RecordWalAppendDiagnostics(long startTicks) + private void RecordWalAppendDiagnostics(long startTicks, int dirtyPageCount) { + Volatile.Write(ref _lastAppendedCommitPageCount, dirtyPageCount); if (startTicks == 0) return; @@ -4887,10 +5124,11 @@ private static void ValidateOptions(PagerOptions options) } } - private static IPageReadProvider CreatePageReadProvider(IStorageDevice device, PagerOptions? options) + private static IPageReadProvider CreatePageReadProvider( + IStorageDevice device, PagerOptions? options, bool copyMappedPages = true) { if (options?.UseMemoryMappedReads == true && device is FileStorageDevice fileDevice) - return new MemoryMappedPageReadProvider(fileDevice); + return new MemoryMappedPageReadProvider(fileDevice, copyOnRead: copyMappedPages); return new StorageDevicePageReadProvider(device); } @@ -4977,12 +5215,12 @@ private void EnforceReaderWalBackpressure(int dirtyPageCount, int ignoredActiveR $"WAL growth limit exceeded while snapshot readers are active (activeReaders={activeReaders}, projectedWalBytes={projectedBytes}, limitBytes={limitBytes.Value})."); } - private BTreeResourceDiagnosticsCounter GetOrAddBTreeResourceDiagnostics(string resourceName) - => _btreeResourceDiagnostics.GetOrAdd(resourceName, static name => new BTreeResourceDiagnosticsCounter(name)); + private BTreeResourceDiagnosticsCounter? GetOrAddBTreeResourceDiagnostics(string resourceName) + => _btreeResourceDiagnostics?.GetOrAdd(resourceName, static name => new BTreeResourceDiagnosticsCounter(name)); private CommitPathBTreeResourceDiagnosticsSnapshot[] GetBTreeResourceDiagnosticsSnapshot() { - if (_btreeResourceDiagnostics.IsEmpty) + if (_btreeResourceDiagnostics is null || _btreeResourceDiagnostics.IsEmpty) return []; CommitPathBTreeResourceDiagnosticsSnapshot[] snapshot = diff --git a/src/CSharpDB.Storage/Paging/ReadOnlyPageAllocator.cs b/src/CSharpDB.Storage/Paging/ReadOnlyPageAllocator.cs new file mode 100644 index 00000000..cb128f60 --- /dev/null +++ b/src/CSharpDB.Storage/Paging/ReadOnlyPageAllocator.cs @@ -0,0 +1,19 @@ +namespace CSharpDB.Storage.Paging; + +/// +/// Rejects snapshot writes without retaining a pager or allocation delegates. +/// +internal sealed class ReadOnlyPageAllocator : IPageAllocator +{ + internal static ReadOnlyPageAllocator Instance { get; } = new(); + + private ReadOnlyPageAllocator() { } + + public ValueTask AllocatePageAsync(CancellationToken ct = default) + => ValueTask.FromException( + new InvalidOperationException("Cannot allocate pages on a read-only snapshot pager.")); + + public ValueTask FreePageAsync(uint pageId, CancellationToken ct = default) + => ValueTask.FromException( + new InvalidOperationException("Cannot free pages on a read-only snapshot pager.")); +} diff --git a/src/CSharpDB.Storage/README.md b/src/CSharpDB.Storage/README.md index ba85ebf9..b54f32b6 100644 --- a/src/CSharpDB.Storage/README.md +++ b/src/CSharpDB.Storage/README.md @@ -317,6 +317,32 @@ Separately from durable flush tuning, the storage write path now does partial as The current crash-level durability coverage is process-based rather than mock-based. The test suite now verifies recovery after a real process crash at four points: immediately after commit returns, at checkpoint start, after checkpoint page copies have been flushed to the main DB file, and after WAL checkpoint finalization but before pager state refresh completes. +## Checkpoint coordination + +Use `Database.CheckpointAsync()` or the owning `Pager.CheckpointAsync()` for a +live database. Checkpoint copying preserves registered readers' page visibility; +finalization waits until no registered snapshot retains WAL frames. Finalization +resets an empty tail or compacts frames committed after the captured copy boundary. + +Finalization acquires the same commit mutex used by write-conflict resolution +before the begin barrier, writer barrier, and checkpoint admission. It holds those +reservations through WAL reset or compaction and pager state refresh, then releases +them in reverse order on completion, cancellation, or error. Pending durable +commits defer finalization because their unpublished frames still own physical +offsets, including legacy commits that already released the writer lock. + +Direct `WalIndex.TakeSnapshot()` and `WriteAheadLog.ReadPageAsync(offset)` calls do +not register a reader with the pager. Low-level callers must coordinate those +reads and all pending writes with checkpointing themselves: a retained offset can +become invalid or point to a different page after reset or compaction. Prefer +`ReaderSession` at the engine layer or the pager's registered snapshot API. + +The [deterministic regressions](../../tests/CSharpDB.Tests/CheckpointConflictReadTests.cs) +cover reset, retained-tail compaction, stale offsets within file bounds, +cancellation, lock release, and subsequent commit/checkpoint progress. The original +CI interleaving was not captured; these tests reproduce the failure mechanism. +The production database and WAL formats remain unchanged. + ## Low-level use: open the storage graph directly If you need direct access to `Pager`, `SchemaCatalog`, or `BTree`, use the default storage engine factory: diff --git a/src/CSharpDB.Storage/Transactions/PagerTransactionState.cs b/src/CSharpDB.Storage/Transactions/PagerTransactionState.cs index 919316df..29e79fd7 100644 --- a/src/CSharpDB.Storage/Transactions/PagerTransactionState.cs +++ b/src/CSharpDB.Storage/Transactions/PagerTransactionState.cs @@ -67,6 +67,9 @@ internal PagerTransactionState( internal bool CommitStarted { get; set; } + // Set only by native conflict validation, before any WAL append/publication. + internal bool CommitRejectedBeforePublication { get; set; } + internal bool Completed { get; set; } internal bool HasSchemaWriteLock { get; set; } diff --git a/src/CSharpDB.Storage/Wal/IWalCheckpointCopyPlan.cs b/src/CSharpDB.Storage/Wal/IWalCheckpointCopyPlan.cs new file mode 100644 index 00000000..b0fed42a --- /dev/null +++ b/src/CSharpDB.Storage/Wal/IWalCheckpointCopyPlan.cs @@ -0,0 +1,17 @@ +namespace CSharpDB.Storage.Wal; + +/// +/// Optional built-in WAL capability for validating snapshot safety before changing +/// the database file. Preparing freezes the exact remaining copy targets; later +/// commits must not change those targets until this checkpoint finishes. +/// +/// +/// The caller holds the checkpoint coordinator's admission lock across preparation, +/// validation, and copying. WAL providers without this capability defer copying +/// while readers are active. +/// +internal interface IWalCheckpointCopyPlan +{ + ValueTask>> PrepareCheckpointCopyAsync( + uint pageCount, CancellationToken ct); +} diff --git a/src/CSharpDB.Storage/Wal/MemoryWriteAheadLog.cs b/src/CSharpDB.Storage/Wal/MemoryWriteAheadLog.cs index 83167b4f..2740fc5c 100644 --- a/src/CSharpDB.Storage/Wal/MemoryWriteAheadLog.cs +++ b/src/CSharpDB.Storage/Wal/MemoryWriteAheadLog.cs @@ -14,7 +14,7 @@ namespace CSharpDB.Storage.Wal; /// public sealed class MemoryWriteAheadLog : IWriteAheadLog, IWalRuntimeDiagnosticsProvider, ICommitPathDiagnosticsProvider, ILiveWalRuntimeSnapshotProvider, IWalRecoveryRuntimeSnapshotProvider, - IWalCheckpointRuntimeSnapshotProvider + IWalCheckpointRuntimeSnapshotProvider, IWalCheckpointCopyPlan { private const int RuntimeDiagnosticsSnapshotMaxAttempts = 3; private const int AppendFrameChunkSize = 16; @@ -554,6 +554,20 @@ public async ValueTask ReadPageIntoAsync(long walFrameOffset, Memory desti } } + internal Func? CheckpointPlanCapturedForTests { get; set; } + + async ValueTask>> IWalCheckpointCopyPlan.PrepareCheckpointCopyAsync( + uint pageCount, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + var checkpoint = Volatile.Read(ref _isOpen) ? await EnsureIncrementalCheckpointStateAsync() : null; + ReadOnlyMemory> pages = checkpoint is null + ? ReadOnlyMemory>.Empty + : checkpoint.CommittedPages.AsMemory(checkpoint.NextPageIndex, + checkpoint.CommittedPageCount - checkpoint.NextPageIndex); + return pages; + } + public async ValueTask CheckpointAsync( IStorageDevice device, uint pageCount, @@ -583,7 +597,7 @@ public async ValueTask CheckpointStepAsync( long requiredLength = 0; try { - var checkpoint = EnsureIncrementalCheckpointState(); + var checkpoint = await EnsureIncrementalCheckpointStateAsync(); if (checkpoint is null) return true; @@ -782,7 +796,7 @@ private async ValueTask RecoverAsync( foreach (var (committedPageId, committedOffset) in uncommittedBatch) _index.AddCommittedFrame(committedPageId, committedOffset); - _index.AdvanceRecoveredCommit(); + _index.AdvanceRecoveredCommit(frameOffset + PageConstants.WalFrameSize); uncommittedBatch.Clear(); } } @@ -1350,12 +1364,14 @@ private void PublishCheckpointProgressMirror(IncrementalCheckpointState checkpoi Interlocked.Increment(ref _runtimeCheckpointProgressVersion); } - private IncrementalCheckpointState? EnsureIncrementalCheckpointState() + private async ValueTask EnsureIncrementalCheckpointStateAsync() { if (_incrementalCheckpoint is not null) return _incrementalCheckpoint; var committedPages = _index.GetCommittedPages(); + if (CheckpointPlanCapturedForTests is { } captured) + await captured(); int committedPageCount = committedPages.Count; if (committedPageCount == 0) return null; @@ -1365,6 +1381,7 @@ private void PublishCheckpointProgressMirror(IncrementalCheckpointState checkpoi int sortedCount = 0; bool isPageIdSortedAscending = true; uint previousPageId = 0; + long retainedWalStartOffset = PageConstants.WalHeaderSize; foreach (var committedPage in committedPages) { if (sortedCount > 0 && committedPage.Key < previousPageId) @@ -1372,6 +1389,8 @@ private void PublishCheckpointProgressMirror(IncrementalCheckpointState checkpoi sortedCommittedPages[sortedCount++] = committedPage; previousPageId = committedPage.Key; + retainedWalStartOffset = Math.Max(retainedWalStartOffset, + checked(committedPage.Value + PageConstants.WalFrameSize)); } if (!isPageIdSortedAscending && committedPageCount > 1) @@ -1379,7 +1398,7 @@ private void PublishCheckpointProgressMirror(IncrementalCheckpointState checkpoi var snapshot = new KeyValuePair[committedPageCount]; Array.Copy(sortedCommittedPages, 0, snapshot, 0, committedPageCount); - var checkpoint = new IncrementalCheckpointState(snapshot, committedPageCount, _storage.Length); + var checkpoint = new IncrementalCheckpointState(snapshot, committedPageCount, retainedWalStartOffset); SetIncrementalCheckpointState(checkpoint); return checkpoint; } diff --git a/src/CSharpDB.Storage/Wal/WalIndex.cs b/src/CSharpDB.Storage/Wal/WalIndex.cs index 946b64f2..fabe0af9 100644 --- a/src/CSharpDB.Storage/Wal/WalIndex.cs +++ b/src/CSharpDB.Storage/Wal/WalIndex.cs @@ -34,6 +34,10 @@ static WalIndex() // Number of committed frames currently in WAL. private int _frameCount; + // Physical end of the last published commit. Keep this independent of the + // mutable page map/count so checkpoint repair has a trusted scan boundary. + private long _publishedWalTail = PageConstants.WalHeaderSize; + // Monotonically increasing commit counter. private long _commitCounter; @@ -123,6 +127,8 @@ internal void PublishCommittedFrames(ReadOnlySpan<(uint PageId, long WalOffset)> _dirtySegments = dirty; _frameCount += frames.Length; + if (!frames.IsEmpty) + _publishedWalTail = frames[^1].WalOffset + PageConstants.WalFrameSize; AdvanceCommitCore(frames.Length); } } @@ -149,6 +155,8 @@ internal void PublishCommittedFrames(ReadOnlySpan frames, long fi _dirtySegments = dirty; _frameCount += frames.Length; + if (!frames.IsEmpty) + _publishedWalTail = firstFrameOffset + (long)frames.Length * PageConstants.WalFrameSize; AdvanceCommitCore(frames.Length); } } @@ -191,11 +199,13 @@ private void AdvanceCommitCore(int committedFrameCount) /// Recovered commits predate this live runtime epoch and therefore do not /// contribute to its logical lifetime commit count. /// - internal void AdvanceRecoveredCommit() + internal void AdvanceRecoveredCommit(long? publishedWalTail = null) { lock (_gate) { _commitCounter++; + if (publishedWalTail.HasValue) + _publishedWalTail = publishedWalTail.Value; } } @@ -400,11 +410,11 @@ internal Dictionary GetCommittedPages() } } - internal (Dictionary LatestPageMap, int FrameCount, long CommitCounter) GetCommittedStateSnapshot() + internal (Dictionary LatestPageMap, int FrameCount, long CommitCounter, long PublishedWalTail) GetCommittedStateSnapshot() { lock (_gate) { - return (CopyCommittedPagesCore(), _frameCount, _commitCounter); + return (CopyCommittedPagesCore(), _frameCount, _commitCounter, _publishedWalTail); } } @@ -418,6 +428,7 @@ public void Reset() { ClearPagesCore(); _frameCount = 0; + _publishedWalTail = PageConstants.WalHeaderSize; } } @@ -447,6 +458,7 @@ internal void ReplaceCommittedState( _frameCount = frameCount; _commitCounter += commitAdvanceCount; + _publishedWalTail = PageConstants.WalHeaderSize + (long)frameCount * PageConstants.WalFrameSize; } } @@ -475,6 +487,29 @@ internal void OverwriteCommittedState( } } + internal bool TryRepairCommittedPages( + Dictionary latestPageMap, + int repairedFrameCount, + int expectedFrameCount, + long expectedCommitCounter, + long expectedPublishedWalTail) + { + lock (_gate) + { + if (_frameCount != expectedFrameCount || _commitCounter != expectedCommitCounter || + _publishedWalTail != expectedPublishedWalTail) + return false; + ClearPagesCore(); + EnsurePageCapacityCore(latestPageMap.Count); + foreach (var entry in latestPageMap) + _pageMap[entry.Key] = entry.Value; + _frameCount = repairedFrameCount; + // Keep the original physical boundary: finalization discards any + // invalid trailing commit while preserving later appended frames. + return true; + } + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private void SetPageCore(uint pageId, long offset) { @@ -578,6 +613,11 @@ internal void Initialize(WalSnapshotMap pageMap, long commitCounter, long minimu public bool HasWalFrames => _minimumWalOffset != long.MaxValue; public long MinimumWalOffset => _minimumWalOffset; + // Map identity also distinguishes replacement/compaction that can reuse counters. + // The counter is required because the empty map is a shared singleton. + internal bool SharesPageState(WalSnapshot other) + => ReferenceEquals(_pageMap, other._pageMap) && _commitCounter == other._commitCounter; + /// /// Look up a page in this snapshot's WAL state. /// diff --git a/src/CSharpDB.Storage/Wal/WriteAheadLog.cs b/src/CSharpDB.Storage/Wal/WriteAheadLog.cs index 6600da83..4bb2a5c3 100644 --- a/src/CSharpDB.Storage/Wal/WriteAheadLog.cs +++ b/src/CSharpDB.Storage/Wal/WriteAheadLog.cs @@ -27,7 +27,7 @@ namespace CSharpDB.Storage.Wal; /// public sealed class WriteAheadLog : IWriteAheadLog, IWalRuntimeDiagnosticsProvider, ICommitPathDiagnosticsProvider, ILiveWalRuntimeSnapshotProvider, IWalRecoveryRuntimeSnapshotProvider, - IWalCheckpointRuntimeSnapshotProvider + IWalCheckpointRuntimeSnapshotProvider, IWalCheckpointCopyPlan { private const int RuntimeDiagnosticsSnapshotMaxAttempts = 3; private const int WalStreamBufferSize = 64 * 1024; @@ -248,6 +248,7 @@ public bool HasPendingCommitWork } } internal IWalFlushPolicy FlushPolicy => _flushPolicy; + internal Func? CheckpointPlanCapturedForTests { get; set; } bool ILiveWalRuntimeSnapshotProvider.TryGetLiveRuntimeDiagnosticsSnapshot( out WalRuntimeRawSnapshot snapshot) @@ -1181,7 +1182,7 @@ private async ValueTask RecoverAsync( { _index.AddCommittedFrame(committedPageId, committedOffset); } - _index.AdvanceRecoveredCommit(); + _index.AdvanceRecoveredCommit(frameOffset + PageConstants.WalFrameSize); uncommittedBatch.Clear(); } } @@ -1477,6 +1478,16 @@ private static void ConsiderRecoveryTruncation( // ============ Checkpoint ============ + async ValueTask>> IWalCheckpointCopyPlan.PrepareCheckpointCopyAsync( + uint pageCount, CancellationToken ct) + { + var checkpoint = await EnsureIncrementalCheckpointStateAsync(pageCount, ct); + return checkpoint is null + ? ReadOnlyMemory>.Empty + : checkpoint.CommittedPages.AsMemory(checkpoint.NextPageIndex, + checkpoint.CommittedPageCount - checkpoint.NextPageIndex); + } + /// /// Checkpoint: copy all committed WAL pages to the database file, then /// truncate (reset) the WAL. @@ -1509,11 +1520,13 @@ public async ValueTask CheckpointStepAsync( try { - var checkpoint = await EnsureIncrementalCheckpointStateAsync(cancellationToken); + var checkpoint = await EnsureIncrementalCheckpointStateAsync(pageCount, cancellationToken); if (checkpoint is null) return true; - long requiredLength = (long)pageCount * PageConstants.PageSize; + // New commits may grow the database between copy slices. Their + // pages belong to a later copy; avoid resizing for every append. + long requiredLength = (long)checkpoint.DatabasePageCount * PageConstants.PageSize; if (device.Length < requiredLength) await device.SetLengthAsync(requiredLength, cancellationToken); @@ -3127,7 +3140,8 @@ private void PublishCheckpointProgressMirror(IncrementalCheckpointState checkpoi Interlocked.Increment(ref _runtimeCheckpointProgressVersion); } - private async ValueTask EnsureIncrementalCheckpointStateAsync(CancellationToken cancellationToken) + private async ValueTask EnsureIncrementalCheckpointStateAsync( + uint pageCount, CancellationToken cancellationToken) { if (_incrementalCheckpoint is not null) return _incrementalCheckpoint; @@ -3138,7 +3152,8 @@ private void PublishCheckpointProgressMirror(IncrementalCheckpointState checkpoi var indexSnapshot = _index.GetCommittedStateSnapshot(); var committedPages = indexSnapshot.LatestPageMap; int committedFrameCount = indexSnapshot.FrameCount; - long committedCommitCount = indexSnapshot.CommitCounter; + if (CheckpointPlanCapturedForTests is { } captured) + await captured(); long streamLength; await _streamMutex.WaitAsync(cancellationToken); @@ -3157,10 +3172,17 @@ private void PublishCheckpointProgressMirror(IncrementalCheckpointState checkpoi streamLength, cancellationToken)) { + // The index map/count may themselves be corrupt. Repair only + // the separately recorded published prefix; later bytes may + // have been appended without becoming durable or visible yet. + long publishedLength = indexSnapshot.PublishedWalTail; + if (publishedLength < PageConstants.WalHeaderSize || publishedLength > streamLength || + (publishedLength - PageConstants.WalHeaderSize) % PageConstants.WalFrameSize != 0) + throw new CSharpDbException(ErrorCode.WalError, "Cannot rebuild checkpoint index from an invalid published WAL extent."); + streamLength = publishedLength; committedPages = new Dictionary(Math.Max(4, committedPages.Count)); var pendingCommitFrames = new List<(uint PageId, long WalOffset)>(capacity: 16); committedFrameCount = 0; - committedCommitCount = 0; _stream.Position = PageConstants.WalHeaderSize; byte[] frameHeaderBuffer = _recoveryFrameHeaderBuffer; @@ -3218,25 +3240,20 @@ private void PublishCheckpointProgressMirror(IncrementalCheckpointState checkpoi continue; committedFrameCount += pendingCommitFrames.Count; - committedCommitCount++; foreach (var (committedPageId, committedOffset) in pendingCommitFrames) committedPages[committedPageId] = committedOffset; pendingCommitFrames.Clear(); } - if (pendingCommitFrames.Count > 0) - streamLength = pendingCommitFrames[0].WalOffset; - - if (_stream.Length != streamLength) - { - _stream.SetLength(streamLength); - _writePosition = streamLength; - SetRuntimeAllocatedBytes(streamLength); - _uncommittedStartOffset = streamLength; - } - - _index.OverwriteCommittedState(committedPages, committedFrameCount, committedCommitCount); + // A publisher may have advanced while the old prefix was read. + // Its newer state must survive this local checkpoint repair. + // Incomplete or invalid commits never enter the rebuilt map. + // Their bytes remain until checkpoint finalization, which can + // discard this exact prefix without truncating a later suffix. + if (!_index.TryRepairCommittedPages(committedPages, committedFrameCount, + indexSnapshot.FrameCount, indexSnapshot.CommitCounter, publishedLength)) + throw new CSharpDbException(ErrorCode.WalError, "Committed WAL state advanced during checkpoint index repair; retry checkpointing."); } } finally @@ -3245,7 +3262,7 @@ private void PublishCheckpointProgressMirror(IncrementalCheckpointState checkpoi } int committedPageCount = committedPages.Count; - if (committedPageCount == 0) + if (committedPageCount == 0 && indexSnapshot.PublishedWalTail == PageConstants.WalHeaderSize) return null; EnsureCheckpointCommittedPageCapacity(committedPageCount); @@ -3253,6 +3270,7 @@ private void PublishCheckpointProgressMirror(IncrementalCheckpointState checkpoi int sortedCount = 0; bool isPageIdSortedAscending = true; uint previousPageId = 0; + long retainedWalStartOffset = indexSnapshot.PublishedWalTail; foreach (var committedPage in committedPages) { if (sortedCount > 0 && committedPage.Key < previousPageId) @@ -3260,6 +3278,8 @@ private void PublishCheckpointProgressMirror(IncrementalCheckpointState checkpoi sortedCommittedPages[sortedCount++] = committedPage; previousPageId = committedPage.Key; + retainedWalStartOffset = Math.Max(retainedWalStartOffset, + checked(committedPage.Value + PageConstants.WalFrameSize)); } if (!isPageIdSortedAscending && committedPageCount > 1) @@ -3267,7 +3287,11 @@ private void PublishCheckpointProgressMirror(IncrementalCheckpointState checkpoi var snapshot = new KeyValuePair[committedPageCount]; Array.Copy(sortedCommittedPages, 0, snapshot, 0, committedPageCount); - var checkpoint = new IncrementalCheckpointState(snapshot, committedPageCount, streamLength); + // The last frame of the captured generation is necessarily the latest + // mapping for its page. Never use a later physical append position here. + // After repair, the trusted tail additionally covers discarded corrupt + // commits whose frames no longer appear in the surviving page map. + var checkpoint = new IncrementalCheckpointState(snapshot, committedPageCount, retainedWalStartOffset, pageCount); SetIncrementalCheckpointState(checkpoint); return checkpoint; } @@ -3285,10 +3309,13 @@ private async ValueTask RequiresCommittedStateRebuildAsync( return true; long maxFrameOffset = streamLength - PageConstants.WalFrameSize; - byte[] frameHeaderBuffer = _recoveryFrameHeaderBuffer; + EnsureCheckpointBuffers(); + EnsureCheckpointCommittedPageCapacity(CheckpointWriteChunkPages); + var validationPages = _checkpointCommittedPages; + int validationCount = 0; + long validationStartOffset = 0; foreach (var entry in committedPages) { - uint expectedPageId = entry.Key; long walOffset = entry.Value; if (walOffset < PageConstants.WalHeaderSize || walOffset > maxFrameOffset) return true; @@ -3296,27 +3323,60 @@ private async ValueTask RequiresCommittedStateRebuildAsync( if ((walOffset - PageConstants.WalHeaderSize) % PageConstants.WalFrameSize != 0) return true; - await ReadWalRangeIntoAsync( - walOffset, - frameHeaderBuffer.AsMemory(0, PageConstants.WalFrameHeaderSize), - cancellationToken); + // Validate every indexed header, but coalesce adjacent WAL frames. + // One asynchronous header read per page makes checkpoint admission + // scale with the entire working set even for a bounded copy step. + if (validationCount > 0 && + (validationCount == CheckpointWriteChunkPages || + walOffset != validationStartOffset + (long)validationCount * PageConstants.WalFrameSize)) + { + if (await RequiresCommittedStateRebuildForBatchAsync( + validationPages, validationCount, validationStartOffset, cancellationToken)) + return true; + validationCount = 0; + } + + if (validationCount == 0) + validationStartOffset = walOffset; + validationPages[validationCount++] = entry; + } + + return validationCount > 0 && + await RequiresCommittedStateRebuildForBatchAsync( + validationPages, validationCount, validationStartOffset, cancellationToken); + } - uint actualPageId = BinaryPrimitives.ReadUInt32LittleEndian(frameHeaderBuffer.AsSpan(0, 4)); - if (actualPageId != expectedPageId) + private async ValueTask RequiresCommittedStateRebuildForBatchAsync( + KeyValuePair[] validationPages, + int count, + long startOffset, + CancellationToken cancellationToken) + { + byte[] readBuffer = _checkpointReadBuffer!; + await ReadWalRangeIntoAsync( + startOffset, + readBuffer.AsMemory(0, (count - 1) * PageConstants.WalFrameSize + PageConstants.WalFrameHeaderSize), + cancellationToken); + + for (int i = 0; i < count; i++) + { + var header = readBuffer.AsSpan(i * PageConstants.WalFrameSize, PageConstants.WalFrameHeaderSize); + uint actualPageId = BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0, 4)); + if (actualPageId != validationPages[i].Key) return true; - uint frameSalt1 = BinaryPrimitives.ReadUInt32LittleEndian(frameHeaderBuffer.AsSpan(8, 4)); - uint frameSalt2 = BinaryPrimitives.ReadUInt32LittleEndian(frameHeaderBuffer.AsSpan(12, 4)); + uint frameSalt1 = BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(8, 4)); + uint frameSalt2 = BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(12, 4)); if (frameSalt1 != _salt1 || frameSalt2 != _salt2) return true; - uint expectedHeaderChecksum = BinaryPrimitives.ReadUInt32LittleEndian(frameHeaderBuffer.AsSpan(16, 4)); + uint expectedHeaderChecksum = BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(16, 4)); uint actualHeaderChecksum = ComputeHeaderChecksum( actualPageId, - BinaryPrimitives.ReadUInt32LittleEndian(frameHeaderBuffer.AsSpan(4, 4)), + BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(4, 4)), frameSalt1, frameSalt2, - frameHeaderBuffer.AsSpan(0, 16)); + header.Slice(0, 16)); if (expectedHeaderChecksum != actualHeaderChecksum) return true; } @@ -3583,17 +3643,20 @@ private sealed class IncrementalCheckpointState public IncrementalCheckpointState( KeyValuePair[] committedPages, int committedPageCount, - long retainedWalStartOffset) + long retainedWalStartOffset, + uint databasePageCount) { CommittedPages = committedPages; CommittedPageCount = committedPageCount; RetainedWalStartOffset = retainedWalStartOffset; + DatabasePageCount = databasePageCount; } public KeyValuePair[] CommittedPages { get; } public int CommittedPageCount { get; } public int NextPageIndex { get; set; } public long RetainedWalStartOffset { get; } + public uint DatabasePageCount { get; } } private sealed class PreparedPendingCommit : IDisposable diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 4184099b..3d71cabc 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -11,7 +11,7 @@ README.md icon.png en - 4.6.5 + 4.6.6 diff --git a/tests/CSharpDB.Admin.Forms.Tests/CSharpDB.Admin.Forms.Tests.csproj b/tests/CSharpDB.Admin.Forms.Tests/CSharpDB.Admin.Forms.Tests.csproj index 0839f121..2435dd1f 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/CSharpDB.Admin.Forms.Tests.csproj +++ b/tests/CSharpDB.Admin.Forms.Tests/CSharpDB.Admin.Forms.Tests.csproj @@ -18,11 +18,12 @@ - + - - - + + + + diff --git a/tests/CSharpDB.Admin.Reports.Tests/CSharpDB.Admin.Reports.Tests.csproj b/tests/CSharpDB.Admin.Reports.Tests/CSharpDB.Admin.Reports.Tests.csproj index ab14783f..b28def85 100644 --- a/tests/CSharpDB.Admin.Reports.Tests/CSharpDB.Admin.Reports.Tests.csproj +++ b/tests/CSharpDB.Admin.Reports.Tests/CSharpDB.Admin.Reports.Tests.csproj @@ -17,10 +17,11 @@ - - - - + + + + + diff --git a/tests/CSharpDB.Api.Tests/CSharpDB.Api.Tests.csproj b/tests/CSharpDB.Api.Tests/CSharpDB.Api.Tests.csproj index 9e5ae1b4..93706deb 100644 --- a/tests/CSharpDB.Api.Tests/CSharpDB.Api.Tests.csproj +++ b/tests/CSharpDB.Api.Tests/CSharpDB.Api.Tests.csproj @@ -17,7 +17,7 @@ - + @@ -29,10 +29,11 @@ - - - - + + + + + diff --git a/tests/CSharpDB.Benchmarks.Tests/CSharpDB.Benchmarks.Tests.csproj b/tests/CSharpDB.Benchmarks.Tests/CSharpDB.Benchmarks.Tests.csproj index 1397407b..e4835bb5 100644 --- a/tests/CSharpDB.Benchmarks.Tests/CSharpDB.Benchmarks.Tests.csproj +++ b/tests/CSharpDB.Benchmarks.Tests/CSharpDB.Benchmarks.Tests.csproj @@ -15,10 +15,11 @@ - - - - + + + + + diff --git a/tests/CSharpDB.Benchmarks/CSharpDB.Benchmarks.slnx b/tests/CSharpDB.Benchmarks/CSharpDB.Benchmarks.slnx new file mode 100644 index 00000000..7bafd4e8 --- /dev/null +++ b/tests/CSharpDB.Benchmarks/CSharpDB.Benchmarks.slnx @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/tests/CSharpDB.Benchmarks/HISTORY.md b/tests/CSharpDB.Benchmarks/HISTORY.md index b34ed192..926e386e 100644 --- a/tests/CSharpDB.Benchmarks/HISTORY.md +++ b/tests/CSharpDB.Benchmarks/HISTORY.md @@ -2,6 +2,3099 @@ This file preserves the benchmark release logs, failed-run notes, investigation records, and detailed historical snapshots that used to live in `README.md`. The main README is now intentionally user-first and release-stable; use this file when investigating why a past benchmark number changed or why a specific run was not promoted. +## October 6, 2026 Common B-Tree Insertion Attribution + +The next bounded diagnostic separated right-edge page acquisition, leaf-cell +construction/insertion and dirty marking in isolated current Storage/Execution +copies. Five broad stages and all right-edge outcomes remain accounted for; +recursive full-leaf fallback cannot overwrite the nested right-edge records. +All product guards, buffer ownership, synchronization and write behavior remain. + +All three builds were warning-free. 149 focused Release cases in 12 verified +classes passed before the two-commit smoke and two fixed 300-commit captures at +sampling masks 63 and 255. All 600 measured durable commits, 6 million measured +rows and 58,918 sampled rows passed durable reopen and independent accounting, +interval/operation containment, distribution, calibration and provenance checks. +Each capture has 2,961,539 hinted successes and 38,461 full-leaf fallbacks; all +leaf helpers used stack cells. There were no other right-edge outcomes. + +| Interval within successful sampled BTree | About 1 in 64 | About 1 in 256 | +|---|---:|---:| +| Page acquisition | 14.55% | 13.35% | +| Cell construction and insertion | 11.26% | 10.72% | +| Dirty-page marking | 19.36% | 20.72% | +| Other work and probe overhead | 54.83% | 55.22% | + +These are nested instrumented elapsed shares, not CPU shares or potential savings. +Clock resolution is 0.1 us: page/cell P50 values are zero ticks, dirty marking one +tick, and all three P95/P99 values one tick in both captures. The 20,000-row empty +nested calibration overlaps these distributions and remains unsubtracted. +Zeros/outliers are retained, including fallback maxima 1,057.4 / 28.2 us; no +device, scheduler, GC or contention cause is established. Fallback samples account +for 17.35% / 12.77% of total sampled BTree time; their full recursive/split path +remains in that broad timer. This is not an allocation or cache-hit classifier. + +No new product candidate is selected. Dirty marking preserves authoritative +buffer and transaction ownership; an already-dirty ID cannot justify skipping it. +The cell-copy bucket is too close to timer calibration to justify a direct-cell +rewrite from this result. Any next optimization needs a distinct mechanism and +evidence less limited by these timers, followed by independent uninstrumented +qualification. Finer timer slicing and rejected patch replays are not justified. + +Evidence: `.tmp/btree-common-path-profile-20261006` retains protocol, original and +instrumented source, builds/tests, pinned runtimes, raw samples and calibration, +all branch counts, split diagnostics, independent audit and artifact manifest. +No retries/exclusions, product/default edits, full-suite repetition, commit, push +or publication. The qualified 860d7ee6 product and prior sealed evidence remain; +the existing 3,803-test qualification and parked/rejected work retain their status. + +## October 6, 2026 Shared Row-ID Hint Advancing-Update Screen + +A fresh isolated Engine candidate replaced ObserveSharedNextRowId's AddOrUpdate +with TryGetValue plus assignment only for an absent or advancing hint, under the +existing _sharedNextRowIdGate. ConcurrentDictionary, concurrent readers, argument +guards, every observation, other mutators and reservation/lease semantics stay +intact. Independent source review confirmed all writers use the same gate. + +The warning-free build and 640 focused Release cases in 15 classes passed. +Four additional reservation/observation cases passed against each arm: lower/equal +explicit hints while another transaction holds IDs1-3, reservation commit/rollback, +next generated ID4, and durable reopen followed by ID5 with exact final rows. +Only Engine.dll differed between performance arms; the baseline remains qualified +860d7ee6 with the original uninstrumented timing harness and pinned dependencies. + +Both two-commit smokes and all four balanced measured processes passed durable +reopen counts/checksums: 900 commits of 10,000 rows after 16 warmups per process, +3,600 measured commits and 36 million rows. The fixed screen failed: insertion +medians improved 0.71% / 3.62%, missing the required >=3% improvement in both pairs. +Whole-operation P50 changed -0.50% / -0.95%, P95 +2.46% / -1.29%, P99 -4.05% / ++19.35%, and throughput including drain +0.50% / -0.33%. Allocation was essentially +unchanged. The mixed P99 result does not independently fail the predeclared +two-pair tail guard; the insertion target alone rejects the candidate. + +Independent raw-data/gate/runtime/test/source checks passed. All samples retained, +no retries or exclusions. Smaller-write guards and full-suite repetition were +skipped as declared. Product code/defaults remain unchanged; no commit, push or +publication. Current product retains its existing 3,803-test qualification. + +Evidence: `.tmp/shared-hint-advancing-update-20261006` preserves protocol/addendum, +source/patch/review, pinned runtimes, builds/tests, supplemental fixtures, raw CSVs, +checkpoint/GC/drain observations, independent audits and sealed artifact manifest. +This candidate is closed without adoption; another replay or threshold relaxation +is not justified. Earlier rejected cache/mapping candidates and the parked serial +durable-flush investigation retain their prior status. + +## October 6, 2026 Row-ID Bookkeeping Substep Attribution + +A fixed diagnostic partitions the current explicit row-ID path after the rejected +single-lookup cache candidate. Isolated Execution and Engine copies retain the +five broad sampled stages and add ordered coercion, cache, dirty-table, shared +callback, lease and schema-hint boundaries. Nested engine timers separate +validation/pre-lock, monitor acquisition, dictionary update and release. +All product behavior, locks, per-row observations and reservation semantics stay +in the current paths; every other product DLL matches the qualified `860d7ee6`. + +All three builds were warning-free. 188 focused Release cases in seven verified +classes passed before captures. The two-commit smoke and fixed 300-commit captures +at xorshift masks 63 and 255 passed exact count/checksum durable reopen. Across +6 million measured rows and 600 durable commits, 47,161 / 11,757 sampled rows +have complete nested records and insertion containment. Every measured row +entered exactly one coercion, observation and shared callback. Independent raw +percentile/share/partition, calibration, ordinal, runtime/source and test-class +audits passed; no retries or exclusions occurred. + +| RowId elapsed substep | About 1 in 64 rows | About 1 in 256 rows | +|---|---:|---:| +| PK assignment coercion | 3.89% | 3.89% | +| Local cache update | 9.96% | 9.48% | +| Dirty-table marking | 5.49% | 5.35% | +| Shared observer callback | 42.03% | 39.86% | +| Lease handling | 2.27% | 2.20% | +| Schema-hint handling | 2.23% | 2.10% | +| Other caller/return/probe elapsed | 34.14% | 37.12% | + +The dictionary update is 57.89% / 57.51% of the nested engine callback interval, +or 13.40% / 12.51% of the instrumented RowId interval. Nested timers cannot be +added to their parents. Stopwatch resolution is 0.1 us; many small steps are +zero/one tick, callback P50 is 0.2 us versus empty calibration 0.1 us, and probe +overhead/residuals are substantial. Calibration/outliers remain unsubtracted. +This establishes a local sampled elapsed ranking, not CPU shares, contention, +historical speedups or a qualified optimization. The single ascending explicit-ID +writer fixture does not measure active auto-ID lease or many-writer cost. + +Source review finds shared-hint mutators serialized by the existing external +gate, while hint readers use ConcurrentDictionary without taking that gate. +The next bounded candidate may simplify only the advancing AddOrUpdate path +under the same gate, preserving ConcurrentDictionary, every observation, high-water +semantics and reservations. It still needs separate uninstrumented qualification; +do not batch observations, remove locks or replay the rejected local-cache patch. + +Evidence: `.tmp/rowid-bookkeeping-profile-20261006` retains raw/calibration records, +all source/runtime identities, protocol, diagnostics, source review, builds/tests, +independent audit and manifest. Product source/defaults and prior sealed evidence +are unchanged; the 3,803-test qualification remains. No full-suite repetition, +commit, push or publication occurred. + +## October 6, 2026 Explicit Row-ID Cache Single-Lookup Screen + +A new isolated Execution candidate used CollectionsMarshal.GetValueRefOrAddDefault +instead of TryGetValue followed by an indexer write in ObserveExplicitRowId. +The high-water maximum is unchanged; the managed reference ends before the +shared observation callback. Positive-ID guards, dirty-table marking, callback +order, reservation lease handling and schema-hint invalidation remain intact. +Current insertion profiling puts the combined row-ID stage at 27.46% / 27.09% +of sampled elapsed time, but does not isolate the dictionary's share. + +The warning-free build and 640 focused Release cases across 15 verified classes +passed, covering identity/high-water/reopen, transactions, concurrency, primary-key +mutations, rowversion, coercion, constraints and storage faults. Both two-commit +smokes passed. The current qualified `860d7ee6` baseline and candidate differed +only in Execution.dll, with the original uninstrumented harness. Four balanced +processes measured 900 commits of 10,000 rows after 16 warmups each: 3,600 durable +commits and 36 million measured rows. All durable reopen counts/checksums and +independent raw-sample, distribution, gate and frozen-input checks passed. + +The fixed performance screen **failed**: insertion medians changed +0.11% / +-1.45%, missing the required >=3% improvement in each pair. Whole-operation +P50 changed -0.04% / +0.11%, P95 -0.47% / -0.18%, P99 +0.88% / -2.17%, and +throughput including drain -0.51% / +2.62%. Allocation was essentially unchanged. +Candidate rejected; conditional smaller guards and a full-suite repeat were +skipped as declared. No product code/default changed, no retries or exclusions, +and no commit, push or publication. The existing 3,803-test qualification remains. + +Evidence: `.tmp/rowid-cache-single-lookup-20261006` preserves protocol, runtimes, +source/patch, builds/tests, raw measurements, GC/checkpoint/drain observations, +analysis, independent audit and artifact manifest. Further work needs narrower +attribution of cache bookkeeping, dirty-table marking, the shared observer and +lease handling before selecting another row-ID mechanism. Earlier mapping +rejections and the parked serial durable-flush tail remain unchanged. + +## October 6, 2026 Deferred Snapshot Mapping Candidate + +An isolated two-file Storage candidate captured the initial snapshot file extent +and deferred creating its mapped view until the first eligible main-file read. +Shared writer mappings remained eager. The current qualified `860d7ee6` baseline +and candidate differed only in Storage.dll and used one identical harness, +memory mapping enabled, default foreground automatic checkpoints at 1000 frames, +tiering off and .NET 10.0.12. Both 8 MB and 84 MB fixtures ran baseline / candidate / +candidate / baseline with balanced standalone point/range order. + +The candidate passed 7 new lifecycle, boundary and WAL-only cases, 101 existing +engine cases across eight mapping/concurrency/cache classes, and 4 pooled ADO.NET +snapshot cases (112 focused tests). All eight measured processes passed query, +value, order, held-snapshot, disposal, automatic-checkpoint, overlap and durable +reopen checks. Raw distributions, ratios, gates and frozen-input preservation +passed an independent audit. There were 4,096 measured durable commits, +64,000 standalone queries, 73,728 mixed queries and 512 held snapshots, +with no measured retries or sample exclusions. + +The declared performance screen **failed** on both fixtures. The candidate did +not reach the required 10% improvement in mixed point, mixed range or whole-cycle +medians in any pair. Mixed range medians increased 2.52-6.49%; the large-fixture +mixed range P99 increased 48.21% / 19.81%. Small-fixture mixed allocation increased +13.92% / 13.65%. Large standalone range medians increased 14.82% / 5.18%, and +one large whole-cycle median increased 6.79%. These failures reject the candidate +despite some transaction-tail improvements. No product code or default changed; +conditional wide-range/mapping-off guards and a full production suite repeat +were not run, as declared in the protocol. No commit, push or publication. + +Evidence: `.tmp/deferred-snapshot-mapping-20261006` retains the fixed protocol, +all source revisions and failed setup tests, zero-warning build receipts, +focused tests, runtimes, fixture hashes, raw measurements, analysis, independent +audit, source encoding amendments and artifact manifest. The initial growth +failure was fixed by using mapping capacity 0 while retaining the captured view +extent. WAL-only tests were corrected to inspect a dedicated snapshot pager. +These corrections occurred before timing. This is local warm evidence with +one reader worker and occasional held snapshots, not cold-disk or many-client +qualification. The earlier mapping-versus-default rejection remains unchanged. + +## October 6, 2026 Mapped Short Reads with Default Automatic Checkpoints + +Current qualified `860d7ee6` binaries were compared with mapping off/on only, +one common harness and the product-default foreground automatic checkpoint +policy (1000 frames). The new workload uses four-row ranges, 16 pooled point +reads and two pooled ranges overlapping each durable four-row UPDATE plus +four-row append; every eighth cycle holds a snapshot. There are no manual +checkpoints inside the measured cycles. Both 8 MB and 84 MB mixed-payload +fixtures ran default/mapped/mapped/default, with balanced standalone query order. + +All correctness, durable reopen, snapshot, reader disposal, interval overlap, +automatic checkpoint, frozen-input and independent analysis checks passed. +Eight processes measured 4,096 durable commits, 64,000 standalone queries, +73,728 mixed queries and 512 held snapshots. No sample exclusions or measured +retries occurred. The exact runtime retains its 3,803-test qualification; +no product source or default changed and no full test suite was repeated. + +The declared mapping performance gate **failed** on both fixtures. Standalone +four-row range medians improved 18.21-24.83%, with about 64.69% less allocation. +Mixed point medians increased 39.64-54.74%, mixed range medians 36.04-48.97%, +and complete cycle medians 19.57-23.59%. Mixed transaction and commit medians +also failed the 5% limits in both pairs. Small-file transaction and commit P99 +failed the repeated 10% limit. The large standalone point median increased +10.27% / 15.37%. Mixed allocation fell 23.57-27.98%; allocation savings do not +override latency failures. Mapping remains opt-in; this does not support a +general default change or erase earlier manual-checkpoint failures. + +Evidence: `.tmp/mapped-short-auto-checkpoint-20261006` retains all raw samples, +fixtures/runtime hashes, protocol, guards, GC observations, final-drain diagnostics, +independent audit and preservation receipts. Initial harness build and launcher +failures are retained: the build was corrected before workloads; the launcher's +measured-only automatic-checkpoint check was corrected for the completed smoke, +which was reused. Measured counts, inputs and numerical gates were unchanged. +This is local warm short-read/automatic-checkpoint evidence, with one reader +worker and occasional held snapshots, not cold I/O or many-client qualification. + +## October 6, 2026 Combined Representative Validation + +The fixed local comparison used exact correctness-qualified `0dc0a36f` and +current `860d7ee6` runtime binaries, identical harnesses, balanced serial order, +and normal desktop load. All builds preceded execution. All correctness, durable +reopen, reader disposal, source/binary identity and independent raw-data checks +passed: 30,800 measured durable commits and 183,600 measured +queries/cycles. No sample was excluded and no workload was retried. + +The complete declared performance screen **passed**, with +0 cases carrying regression flags. Detailed per-case/pair +P50/P95/P99, maxima, allocations, throughput and target checks are preserved in +`.tmp/combined-validation-20261006/analysis-r2/Findings.md` and +`analysis-r2/analysis.json`. + +The 10,000-row insert-phase median changed -18.49% / +-18.10%; throughput including final drain changed ++12.06% / +11.92%. + + +This is representative local validation, not a release sweep or published-table +update. Baseline already has plan reuse and checkpoint optimization. Read fixtures +contain 10,000 rows with 1/256-table catalogs; mapping is off. The mixed case is +a sequential four-row UPDATE/commit plus pooled read. Earlier concurrent/larger-file +evidence, blocked qualification and failed tail/candidate screens remain unchanged. +No production code changes or commit were needed. + +## October 6, 2026 Current Insert Profile and Rejected Known-Key Hint Screen + +A fixed current-baseline diagnostic at `860d7ee6` captured 300 durable commits +of 10,000 rows at each of two deterministic sampling rates, 1 in 64 and 1 in 256, +after 16 warmups. All 6 million measured rows, 47,161 / 11,757 sampled rows and +77,204 allocation intervals passed accounting and exact durable reopen. Source +instrumentation is isolated; the historical pre-ordinal profile stays separate. + +| Sampled elapsed stage | About 1 in 64 rows | About 1 in 256 rows | +|---|---:|---:| +| RowId | 27.46% | 27.09% | +| RowVersionValidation | 16.89% | 16.21% | +| Encoding | 15.25% | 16.66% | +| BTree | 34.88% | 34.20% | +| LogicalWrite | 5.51% | 5.84% | + +These are sampled elapsed shares, not CPU percentages or a speedup comparison. +Each capture records 38,602 new page allocations matching 38,461 right-edge +leaf splits and 141 right-edge interior splits. Page allocation occupies +5.46% / 5.63% of full insertion elapsed and, in matched samples, 9.07% / 9.13% +of B-tree elapsed. Nonallocation rows contribute about 83.5% of sampled B-tree +elapsed. Timers are nested; their scopes cannot be added. All rare allocation +and nonallocation outliers remain. Live buffer ownership/lifetime is unchanged. +Encoding already uses the encodedLength overload and repeated short-text cache. + +One isolated Storage candidate supplied the just-inserted maximum key when +refreshing the right-edge hint, retaining all page guards and next-insert actual +maximum-key validation. Its warning-free build and 149 focused Release cases +passed across 12 verified classes. Two nonexistent initial test class names +caused the selection-accounting check to fail; that attempt is preserved, and +four previously unexecuted current transaction/extensibility classes completed +the corrected coverage without repeating passed cases. + +The original uninstrumented ABBA timing harness ran 900 commits of 10,000 rows +per process on the current qualified baseline. All 3,600 measured commits and +36 million measured rows passed exact durable reopen; no measured retries or +exclusions occurred. Insertion medians improved only 0.15% / 2.11%, failing the +unchanged >=3% improvement requirement in EACH pair. Total P99 changed +1.70% / ++1.94%; throughput including drain changed +0.69% / +3.30%. Those observations +do not override the failed screen. Candidate rejected; smaller guards and +product edits were skipped. No production source change is retained. + +Evidence, raw intervals, rejected source/binaries and independent audits: +`.tmp/insert-profile-refresh-20261006` and +`.tmp/btree-right-edge-known-key-20261006`. The existing 3,803-test product +qualification remains. All prior work/evidence is preserved; no commit/push. + +## October 6, 2026 Controlled Durable-Flush Cadence (Tail Target Parked) + +A new fixed natural / paced / paced / natural diagnostic used the current +qualified ordinal/coercion product binaries at `860d7ee6`, with the retained +common direct-I/O Storage probe and one common harness. It measured 900 durable +commits of 1,000 rows after 64 warmups per process on the normal desktop. All +3,600 commits and 10,212 I/O records passed exact capture and durable reopen; +there were no retries, sample exclusions or competing builds/tests. + +Pacing waits at least 3 ms after a completed commit before beginning the next +transaction. Actual median foreground flush gaps widened from 0.8666 / 0.8634 ms +to 13.2938 / 13.3005 ms; timer overshoot remains recorded. This tests a wider +cadence, not a precise 3 ms gap or the historical submillisecond gap difference. +Each pair required identical write/maintenance shapes, achieved cadence shift, +direct-flush P95 improvement >=10% and P99 change <=+10%. + +| Pair | Direct flush P95 | Direct flush P99 | Commit P99 | Throughput incl. pacing and drain | +|---|---:|---:|---:|---:| +| Natural then paced | -3.01% | +4.87% | +4.14% | -78.69% | +| Paced then natural | -5.85% | +3.57% | +6.36% | -78.80% | + +The declared tail-response gate failed both pairs. Commit >5 ms counts were +5 / 2 / 4 / 3; whole-operation P99 changed -4.58% / +4.43%. Every slot retained +1,647 foreground writes / 62,714,640 bytes with identical per-operation offsets +and lengths, exactly one direct flush per measured commit, and three maintenance +header-write/flush pairs inside begin phases 243 / 486 / 729. Matching only the +900 foreground calls leaves 2.18-2.90 ms cumulative wrapper residual per process. + +No production pacing or Storage change is adopted. Park this serial durable-tail +target pending a materially new mechanism; next is current insertion profiling. +The study does not prove cadence independence, identify OS/device/scheduling +causes, explain historical ordinal tails, or erase earlier mixed results. Added +idle time is included in wall throughput. No performance optimization is qualified. + +Evidence and independent raw-QPC audit: `.tmp/medium-controlled-cadence-20261006`. +The existing 3,803-test source qualification remains; it was not rerun for this +isolated harness-only diagnostic. All prior evidence and parked work are preserved. + +## October 6, 2026 Medium Commit Tail: Direct WAL I/O Attribution + +A fixed diagnostic ABBA comparison used common instrumented Storage and harness +with the retained ordinal baseline/candidate Execution DLLs: 900 commits of 1,000 +rows per process, 64 warmups, normal desktop, no retries. All 3,600 measured +commits, 10,212 I/O records and durable reopen checks passed; there were no dropped, +incomplete, boundary-raced or window-crossing records. This diagnostic does not +include the later coercion optimization or replace uninstrumented qualification. + +Direct `RandomAccess.FlushToDisk` elapsed accounts for **99.81-99.89%** of the +cumulative commit-wrapper flush timer. The initial runner subtraction compared +903 policy flushes with 900 commit wrappers; its negative `wrapperMinusDirectMs` +values are a scope mismatch, not overhead. Three extra policy flushes follow +32-byte offset-0 maintenance writes within begin phases 243, 486 and 729. +Matching exactly 900 foreground calls yields positive wrapper residuals of +3.8738 / 2.1075 / 2.7436 / 2.1810 ms across the four processes (2.34-4.30 us per +commit on average). Original outputs remain unchanged. + +| Slot | Commit P95 | Commit P99 | Direct flush P99 | Commits >5 ms | +|---|---:|---:|---:|---:| +| 0 baseline | 3.5867 ms | 4.4707 ms | 3.8490 ms | 6 | +| 1 candidate | 2.9733 ms | 3.5761 ms | 3.2599 ms | 3 | +| 2 candidate | 3.2831 ms | 4.3267 ms | 3.6008 ms | 4 | +| 3 baseline | 3.0081 ms | 4.1241 ms | 3.8883 ms | 8 | + +Candidate commit P95 changes are -17.10% and +9.14%; direct flush occupies +85.60-94.29% of >5 ms commit time. Both arms retain write-related and other +residuals. Every operation's write offsets and lengths match across all four +processes: 1,647 foreground writes / 62,714,640 bytes and exactly 900 foreground +flushes. The candidate has shorter median inter-flush gaps in both pairs, but no +controlled cadence experiment establishes a timing-mediated mechanism. + +The earlier both-pair diagnostic P95 regressions remain recorded; their direction +did not consistently repeat in this differently instrumented study. No candidate- +specific I/O shape mechanism or causal host/device explanation is established. +Elapsed around the direct call includes runtime/OS/scheduling effects; it is not +pure device service time. No production Storage change is adopted. + +Evidence: `.tmp/medium-wal-io-attribution-20261006/Findings.md`, `Findings.json`, +raw per-operation timestamps and `probe/io.json` records. All samples retained. + +## October 6, 2026 Canonical Assignment Fast Paths: Qualified and Adopted + +`SqlTypeCoercion.CoerceForAssignment` now returns already-canonical values for +declared BIGINT with an integer value, INTEGER with an in-range integer value, +and TEXT with a text value. The column/null guards and original conversion and +error-wrapping body remain; all other assignments use that body through a private +helper. Legacy metadata, BLOB/bit metadata, facets, coercion order, NOT NULL/CHECK +order and partial row mutation behavior are preserved. Actual JIT inlining was +not inspected; the measured result below is an end-to-end improvement. + +The isolated candidate passed 183 existing Release cases across 12 relevant +classes. Another 18 regression cases passed against both baseline and candidate, +covering integer extrema and exact failures, text code units/reference identity, +numeric fallback, argument guards, mixed-row failure precedence and mutation, +and logical versus legacy BLOB behavior. Both isolated builds had zero warnings. + +The fixed comparison used the original uninstrumented durable harness with +900 measured commits of 10,000 rows per process, 16 warmups, and ABBA ordering. +The baseline is the qualified, committed column-ordinal implementation. Only +Execution differed between paired runtimes. Each pair required insertion-median +improvement of at least 3%, whole-operation P50/P95 increases within 5%, and +allocation increases within 1%; P99 could not exceed +10% in both pairs, and +median paired throughput had to be nonnegative with and without final drain. + +| Pair | Insertion median change | Whole-operation P50 | P95 | P99 | Throughput including drain | +|---|---:|---:|---:|---:|---:| +| Baseline then candidate | -11.17% | -7.17% | -8.02% | -2.57% | +7.74% | +| Candidate then baseline | -8.55% | -7.39% | -2.37% | -9.60% | +5.27% | + +Median paired throughput including drain improved 6.50%. Allocation remained +approximately 584 KB per commit, and all large slots ended with the same +17,551,232-byte WAL extent. These numbers apply to the measured integer/text +batch workload, not every SQL type or assignment call site. + +The fixed 1,000-row and single-row guards passed. Medium P95 improved +18.92% / 16.49%, P99 improved 31.69% / 8.15%, and throughput including drain +improved 11.41% / 9.05%. Single-row P50 improved 3.05% / 4.93%, throughput +including drain improved 4.81% / 4.17%, and P99 changed -2.96% / +14.81%. +The one-pair single-row tail regression is retained. Smaller-write rejection +requires the same P50, P99, allocation or throughput threshold to fail both +pairs; P95 is reported separately. This does not establish a general tail fix. + +All 12 processes / 27,200 measured commits / 39.62 million measured rows passed +exact count/checksum durable reopen, including warmups. No measured retries, +sample exclusions, idle gates, unrelated process termination or competing builds +or tests were used. All samples and maxima are retained in the evidence. + +The exact candidate source and 18 regression cases were then applied to the +product. The benchmark/crash harness, engine and ADO outputs were rebuilt in +Release before tests. All 3,478 engine and 325 ADO tests passed: 3,803 total, +zero failures/skips. Builds had zero warnings except the existing xUnit1069 +warning in unchanged PooledMemoryMappedSnapshotTests.cs:14. Rebuilt Engine and +Execution DLL hashes matched across all three outputs. A final product-binary +correctness smoke durably reopened 180,000 rows with checksum 16,200,090,000. + +Evidence and binary/source hashes are in +`.tmp/assignment-coercion-fastpath-20261006`. The earlier ordinal commit-tail +investigation remains a separate open issue; coercion does not alter WAL flush +semantics. Unrelated work and rejected experiments remain preserved. + +## October 6, 2026 Medium Commit Tail: Flush-Path Diagnostic + +A fixed ABBA diagnostic reused the earlier ordinal comparison's exact frozen +baseline/candidate binaries, 64 warmups and 900 commits of 1,000 rows per process. +Trace mode captures per-operation diagnostic snapshots and is separate from +performance qualification. All 3,600 measured operations passed durable reopen. + +Both candidate whole-operation P95s worsened: +24.75% / +28.03%. Commits above +4 ms occurred 24 / 114 / 131 / 15 times in ABBA order; above 5 ms, 15 / 60 / 63 / 5. +Durable-flush-path elapsed time accounts for 94.48% / 84.01% of the candidate +time spent in commits above 5 ms. WAL-append timers contain flush/publication; +the analysis subtracts nested intervals rather than adding overlapping timers. + +The original 716-726 burst did not recur, but baseline slot 0 had an 11-operation +flush-dominated burst at 565-575. This does not explain the candidate's more +frequent slow commits. Candidate slot 2 also had a 21.2240 ms commit with +16.0375 ms append residual and 5.0478 ms flush time. Total P99 observations can +instead involve insertion outliers, so not every total tail is a flush stall. + +The source path ends at DurableWalFlushPolicy.FlushCommitAsync and +RandomAccess.FlushToDisk. Its current timer also includes counter/observer +bookkeeping. These observations locate elapsed time but establish neither the +underlying cause nor host interference. All samples, checkpoint observations, +matched quantile samples and residuals are preserved in +`.tmp/medium-commit-tail-20261006`. The next diagnostic separates the direct +flush call and preceding WAL write size/offset/duration, retaining durability. + +## October 6, 2026 Statement Column Ordinal Reuse: Corrected, Qualified and Adopted + +Bare simple inserts now resolve primary-key and rowversion column positions +once per statement and pass them through the row helpers. Coercion, validation, +identity allocation, shared row-ID observations and rowversion generation still +run per row. There is no cross-statement schema cache. Only `QueryPlanner.cs` +changed in production source. + +This corrects the isolated v1 candidate's extra fallback scan. An optional known +rowversion position is used when available; other paths discover it at the +original validation point. A four-case counting probe compared the product, +rejected v1 and corrected v2. On explicit-column inserts, reads of the four-column +schema changed **7 → 11 → 7** for both absent and omitted rowversion columns; +explicit rowversion rejection changed **5 → 9 → 5** with identical error code +and message. Full-row fallback remained at four reads. This is exact helper +access accounting, not a timing comparison. **180 existing Release tests passed** +across all 11 requested identity, rowversion, constraint, concurrency, type and +row-buffer classes. Candidate and probe builds had zero warnings/errors. + +The corrected candidate independently passed the **unchanged acceptance limits** +from the earlier failed screen. The original uninstrumented durable harness ran +900 measured commits of 10,000 rows, with 16 warmups, in fixed ABBA order. Only +Execution differed between paired runtime directories. Each pair had to improve +insertion median by at least 3%, keep whole-operation P50/P95 increases within +5% and allocation increases within 1%; the same P99 metric could not exceed +10% in both pairs, and median paired throughput had to be nonnegative with +and without final drain. + +| Pair | Insertion median before → after | Insertion change | Whole-operation P50 change | P95 change | P99 change | Throughput incl. drain change | +|---|---:|---:|---:|---:|---:|---:| +| Baseline then candidate | 6.77985 → 5.9786 ms | -11.82% | -7.41% | -5.07% | -2.77% | +8.27% | +| Candidate then baseline | 6.4641 → 5.9749 ms | -7.57% | -3.83% | +0.68% | +3.63% | +2.75% | + +Median paired throughput including drain improved **5.51%**. Allocation remained +approximately 584 KB per commit. All four large slots ended with the same +17,551,232-byte WAL extent; maxima were 48.24–56.77 ms. These results support +the measured large-batch improvement, not a general tail-latency claim. + +The fixed smaller-write guards also passed in ABBA order: 900 commits of 1,000 +rows and 5,000 single-row commits per process. The 1,000-row P99 changes were +**-9.99% and +34.66%**; second-pair P95 also increased **11.20%**. Retain those +regressions: the predeclared smaller-write guard rejects a median/P99/allocation +or throughput limit only when the same metric fails both pairs, and P95 is +reported separately. Its P50 improved 1.60–2.68%, and throughput including drain +changed +4.14% and +0.01%. Single-row P50 increased 0.25–0.29%, P99 decreased +0.57–1.66%, and throughput including drain decreased 0.21–0.29%, within the +fixed limits. No cause is established for the smaller-batch tail differences. + +All **12 comparison processes / 27,200 measured commits / 39.62 million measured +rows** passed exact count/checksum durable reopen, including warmup rows. Runs +used the normal desktop, with no idle gates, unrelated process termination, +competing builds/tests, measured retries or discarded samples. The rejected v1 +screen and its original evidence remain unchanged. + +After applying the exact candidate source, the crash harness, engine and ADO +test outputs were rebuilt in Release. **3,460 engine + 325 ADO = 3,785 tests +passed**, with no failures or skips. The crash-harness and engine builds had +zero warnings; ADO reported only the preexisting xUnit1069 timeout-token warning +in unchanged `PooledMemoryMappedSnapshotTests.cs:14`. All three outputs used the +same rebuilt Engine and Execution DLLs. A final correctness-only smoke on the +product binaries durably reopened 180,000 rows with checksum 16,200,090,000. + +Evidence, source snapshots, all samples, test results and final binary hashes +are sealed in `.tmp/insert-column-ordinals-v2-20261006`. Unrelated work and prior +experimental WAL changes are preserved separately from this adopted change. + +## October 6, 2026 Insert Phase Attribution and Statement Column Ordinals: Screen Rejected + +An isolated Execution build sampled five nonoverlapping stages of bare-row +insertion on the committed `4e59bebf` baseline. A deterministic xorshift sampler +selected approximately one row in 64, then one in 256, in two fixed 300-commit +captures of 10,000 rows per commit. Both used 16 warmups and retained all selected +samples. A preceding two-commit smoke and both captures passed exact durable +count/checksum reopen. Phase counts covered all 3 million measured rows in each +capture, with **47,161 and 11,757 sampled rows** respectively. + +| Sampled elapsed stage | 1-in-64 share | 1-in-256 share | +|---|---:|---:| +| Row ID, coercion and local/shared hint updates | 28.49% | 26.22% | +| Rowversion, row validation and generated-key metadata | 24.58% | 21.58% | +| Encoding | 12.48% | 11.52% | +| B-tree insertion | 29.91% | 36.44% | +| Logical-write recording | 4.54% | 4.24% | + +These are elapsed shares of instrumented sampled stages, not CPU attribution or +a speedup comparison. The clock resolution was 100 ns; empty-stage calibration +averaged approximately 12–21 ns and was retained without subtracting it. The +sparser capture includes an **821.4 microsecond B-tree sample**, which remains +in the totals. No cause is established for that sample or the rate-to-rate +differences. Diagnostic Execution produced two JSON serialization trimming/AOT +warnings; the diagnostic harness and subsequent uninstrumented candidate builds +had zero warnings/errors. No diagnostic code entered production. + +Source review found repeated primary-key and rowversion column scans per row. +An isolated candidate resolved these ordinals once per bare insert statement, +then passed them through the existing helpers. Per-row coercion, validation, +identity generation and shared hint updates remained in place, with no cache +across statements. **180 existing Release tests passed** across identity, +rowversion, default/CHECK constraints, concurrency, numeric/type coercion, +logical keys and row buffers. Counts come from executed test classes; one +initially requested nonexistent class contributed no tests, and explicit +integer/type/buffer suites were subsequently run before measurement. + +The original uninstrumented durable harness ran **900 measured 10,000-row +commits per process** in fixed ABBA order, with 16 warmups. Only Execution +differed between runtime directories. The fixed screen required at least 3% +insertion-median improvement in each pair, whole-operation P50/P95 increases +no greater than 5%, allocation increases no greater than 1%, no P99 increase +above 10% in both pairs, and nonnegative median paired throughput with and +without final drain. + +| Pair | Insertion median before → after | Insertion change | Whole-operation P50 change | P95 change | Throughput incl. drain change | +|---|---:|---:|---:|---:|---:| +| Baseline then candidate | 6.9679 → 5.98465 ms | -14.11% | -9.98% | -9.08% | +10.16% | +| Candidate then baseline | 6.7171 → 6.56365 ms | -2.28% | -9.01% | -3.54% | +29.22% | + +The second pair missed the **3% insertion improvement gate**, so the screen is +rejected and smaller-write guards/adoption were skipped. Allocation was +effectively unchanged. Baseline slot 3 had P99 **227.28 ms** and maximum +**299.09 ms**, versus candidate slot 2 P99 42.74 ms and maximum 56.03 ms. Retain +that baseline tail; the large throughput change does not establish a typical +gain or its cause. All four processes / **3,600 measured commits / 36 million +measured rows** passed exact durable reopen, including warmup rows. No measured +slot was retried, excluded or replaced; all runs used the normal desktop. + +Further review also found that the candidate's convenience wrapper adds an +unnecessary rowversion-column scan to other explicit-column insert paths. +Correct that fallback-path issue before any further qualification. The initial +candidate and failed screen remain preserved; no change to production source +or qualified binaries was adopted. Evidence is in +`.tmp/insert-phase-attribution-20261006` and +`.tmp/insert-column-ordinals-20261006`. + +## October 6, 2026 Remaining Insert Allocation and Dirty-Page Callback: Screen Rejected + +The row-ID observer fix was committed as `4e59bebf`. A fresh insertion profile +used its qualified product DLLs with the unchanged diagnostic harness and +analyzer. The two-commit smoke, 300-commit phase capture and separate 300-commit +trace all passed exact count/checksum durable reopen. The phase capture measured +zero warmed row-fill allocation; filling accounted for 2.61% of insertion time. +These instrumented timings are diagnostic, not an optimization comparison. + +The trace contained 1,624 measured allocation ticks and 1,962 managed thread +samples, with zero lost events or missing stacks. Page allocation accounted for +1,487 ticks; the row-ID observer no longer appeared in allocation stacks. The +remaining dirty-page callback appeared in 24 ticks. Sampled interval weights +are not exact allocated bytes by type, and managed thread samples do not rank +CPU cost. Page buffers retain live data, so pooling them requires a separate +ownership design rather than simply returning them after an insert. + +An isolated Storage candidate changed the dirty-page callback to a cached static +delegate with explicit Pager state, preserving its existing cache, transaction, +snapshot-rejection and page-ownership paths. It added no per-Pager field or +reader allocation. Both builds had zero warnings/errors, and **45 existing +Release storage tests passed**, including bounded-cache mutation, dirty-buffer, +snapshot, overflow and fault recovery coverage. A warmed 100,000-call cached-page +probe allocated **6,400,128 bytes before and 128 bytes after**: exactly 64 fewer +bytes per call, with the same small residual in both processes. This probe does +not measure end-to-end latency. + +The original uninstrumented durable workload then ran **900 measured commits of +10,000 rows per process**, 16 warmups, in fixed ABBA order. Only Storage differed +between the two runtime directories. The predeclared minimum useful allocation +reduction was 1%, reflecting this smaller target; the row-ID qualification's +latency limits and nonnegative median paired throughput requirement remained. + +| Pair | Allocation/commit before → after | P50 change | P95 change | P99 change | Throughput incl. drain change | +|---|---:|---:|---:|---:|---:| +| Baseline then candidate | 584,184 → 575,937 B | +3.62% | +0.69% | +0.86% | -4.84% | +| Candidate then baseline | 584,191 → 575,960 B | -3.42% | -3.25% | +0.57% | +3.95% | + +Allocation decreased **1.41% in both pairs**, about 8.2 KB per commit, and all +latency gates passed. However, median paired throughput changed **-0.44% including +drain and -0.43% excluding drain**, failing the fixed nonnegative throughput gate. +The screen is rejected; smaller-workload guards and product adoption were +skipped. Opposite-direction changes do not establish a consistent latency effect +or a cause for the throughput miss. No slot was retried or excluded. + +All four comparison processes / **3,600 measured commits / 36 million measured +rows** passed exact durable reopen, including their warmup rows. Runs used the +normal desktop with no idle gates, unrelated process termination or competing +builds/tests. Production source and qualified binaries remain at the committed +row-ID fix. Evidence is preserved in `.tmp/remaining-insert-profile-20261006` and +`.tmp/dirty-page-callback-20261006`; the rejected patch remains isolated there. + +The next investigation should measure repeated per-row bookkeeping and B-tree +insertion cost. `ExecuteBareSimpleInsertAsync` still resolves/coerces each key, +updates local and shared row-ID hints, validates and encodes each row, then +inserts it. Any attempt to consolidate hint updates must preserve visibility to +concurrent identity reservations, mixed explicit/generated keys and partial +statement failure. Their relative CPU costs have not yet been established. + +## October 6, 2026 Row-ID Observer Allocation Fix: Confirmed and Adopted + +`Database.ObserveSharedNextRowId` now passes the observed ID as explicit state +to cached static `ConcurrentDictionary.AddOrUpdate` factories. This removes +the previously measured **88 bytes per observation** from a captured closure +and delegate. The existing lock, argument checks, case-insensitive key handling +and maximum-hint semantics are unchanged. Only this method changed in product +source; no checkpoint, WAL-format or durability policy changed. + +The preceding 300-commit screen remains a recorded failure: its second pair +exceeded the 5% whole-operation P95 limit by reporting +6.06%. A separately +declared confirmation used the **exact same candidate, baseline and timing +harness binaries**, with 900 measured commits of 10,000 rows per process and +16 warmups. Eight fresh processes ran in ABBA/BAAB order, producing four adjacent +pairs with balanced direction. Increasing the count gives 45 observations in +the upper five percent of each process instead of 15 and crosses more checkpoint +generations. The same median/P95 limits applied to every pair; no failed slot +was retried, excluded or replaced, and no source or threshold was tuned. + +All four pairs passed the large-workload gates: + +| Pair / slots (candidate, baseline) | P50 change | P95 change | P99 change | Throughput incl. drain change | Allocation change | +|---|---:|---:|---:|---:|---:| +| 1 / (1, 0) | -3.80% | -1.73% | -0.86% | +3.47% | -60.10% | +| 2 / (2, 3) | -4.50% | -3.38% | -1.30% | +4.96% | -60.10% | +| 3 / (4, 5) | -6.40% | -3.10% | -3.15% | +14.27% | -60.10% | +| 4 / (7, 6) | -4.62% | -0.84% | -2.12% | +4.49% | -60.10% | + +Allocation fell from approximately **1.464 MB to 0.584 MB per commit**; median +paired throughput including final drain improved **4.72%**. The largest +throughput change includes a retained **227.85 ms baseline maximum** in slot 5; +the other baseline maxima were 45.55–48.44 ms and candidate maxima were +47.37–52.92 ms. Do not interpret the largest throughput pair as the typical +gain or this result as removal of the intermittent extreme tail. Every large +slot ended with the same 17,551,232-byte WAL extent before final drain. + +The fixed smaller-write guards also passed in ABBA order: 900 measured commits +of 1,000 rows, then 5,000 single-row commits per process. The 1,000-row pairs +reduced allocations by 59.24–59.25% and P50 by 1.31–1.54%; P99 increased +**34.82% and 7.56%** (4.9374 → 6.6565 ms and 4.6399 → 4.9905 ms). That isolated +P99 miss is retained: the predeclared guard rejects the same metric exceeding +10% in both pairs, which did not occur. Single-row allocation decreased by +3.52–3.58%, P50 increased 0.34–0.73%, P99 increased 1.68–4.01%, and throughput +including drain decreased 0.99–1.44%, all within the fixed limits. + +All **16 comparison processes / 30,800 measured commits / 75.62 million measured +rows** passed exact count/checksum durable reopen. Runs used the normal desktop, +with no idle gates, unrelated process termination or competing builds/tests. +This larger fixed confirmation supports adoption; it does **not** establish why +the preceding short screen missed P95 or attribute it to host noise. + +After applying the exact tested source change, the crash harness, engine-test +and ADO-test outputs were rebuilt in Release. **3,460 engine + 325 ADO = 3,785 +tests passed**, with no failures or skips. The engine/crash-harness builds had +zero warnings; the ADO build reported the existing xUnit1069 timeout-token +warning in unchanged `PooledMemoryMappedSnapshotTests.cs:14`. All three outputs +used the same rebuilt Engine DLL. A correctness-only smoke on the final product +binaries durably reopened all 180,000 rows with the exact checksum. + +The first adoption-script guard stopped before any source edit or build because +the saved method and source had different line endings. Its corrected matcher +preserves the source's line endings and all bytes outside the method; the failed +setup and correction are retained. No measured benchmark was rerun for that fix. + +Evidence is sealed in `.tmp/row-id-latency-confirmation-20261006`, including the +fixed protocol, every capture, prior failed-screen integrity checks, source +snapshots, full test logs and final binary hashes. Unrelated changes and earlier +experimental WAL work are preserved separately from this adopted fix. + +## October 6, 2026 Insert Profiling and Row-ID Observer Allocation Candidate + +The next investigation split the existing 10,000-row insertion phase into +reusable-row filling and batch execution. A normal-desktop diagnostic process +ran 16 warmup and 300 measured durable commits with the same schema, values, +4096-frame preset, final drain and exact durable reopen as the prior workload. +Filling rows accounted for **2.36%** of insertion-phase time and allocated +**zero thread-local bytes** after warmup; execution accounted for the rest. +The diagnostic timers are not an optimization comparison. + +A separate 300-commit EventPipe capture passed exact durable reopen and marker +validation with **zero lost events**, 2,320 managed thread samples and 4,103 +allocation ticks, all with stacks. Of those allocation ticks, **2,467** pointed +to `Database.ObserveSharedNextRowId`. Source review found that its `AddOrUpdate` +lambda captures `nextRowId`, creating a closure and delegate for each explicit +primary key. Sampled interval weights are not exact bytes by type, and managed +thread presence does not establish a CPU ranking. + +An isolated Engine candidate replaces that captured callback with cached static +factories and an explicit `long` argument. It preserves the existing lock, +argument checks, case-insensitive dictionary and maximum-hint semantics. No +other product source differed. A warmed, synchronous bound-delegate probe of +100,000 observations measured **88 bytes/call before and zero after**, checking +that lower/zero/negative observations did not lower the final hint. **93 existing +Release identity, insert, constraint and concurrency tests passed** against the +isolated candidate. All diagnostic/candidate builds had zero warnings/errors. + +The allocation result held in the original uninstrumented durable harness, but +the candidate **did not pass the whole-operation latency screen**. Two 80-commit +smokes preceded a fixed ABBA comparison, with 300 measured 10,000-row commits +per slot. All six exact durable reopens passed. Dependencies and harness bytes +were identical except Engine; no measured process was retried or excluded. + +| Pair | Allocation/commit before → after | Insertion median before → after | Whole-operation P95 before → after | Throughput incl. drain change | +|---|---:|---:|---:|---:| +| Baseline then candidate | 1,468,609 → 588,663 B | 6.6498 → 6.1970 ms | 22.2188 → 19.8405 ms | +9.62% | +| Candidate then baseline | 1,468,630 → 588,596 B | 6.5313 → 6.3534 ms | 19.7425 → 20.9389 ms | -1.62% | + +Allocation fell **59.92% in both pairs**, approximately 880 KB per commit. +Insertion medians improved **6.81% and 2.72%**; whole-operation medians improved +5.23% and 0.50%. However, second-pair whole-operation P95 increased **6.06%**, +above the fixed 5% limit for either pair. P99 changed -8.68% and +8.67%. +The other large-screen gates passed, including median paired throughput, but +that P95 miss stopped the experiment before smaller-workload guards or full +candidate qualification. No timing cause is established: the phase distributions +and individual P95 operations are retained without attributing the miss to the +host, checkpoint scheduling or the optimization itself. + +Keep the allocation fix as a promising isolated candidate, not an adopted +performance change. Production sources and qualified binaries remain unchanged. +Next qualification must address the whole-operation latency miss before shipping +it; do not erase this result with a favorable rerun or silently relax its gate. +Evidence: `.tmp/insert-loop-profile-20261006` contains the profile and +`.tmp/row-id-observer-20261006` contains the candidate, focused tests, exact +allocation probe and all paired captures. Prior work and unrelated edits are +preserved; nothing from this phase has been committed. + +## October 6, 2026 Larger Compaction Batches: Screen Rejected + +A compaction-only candidate increased the maximum move from 16 to **256 whole +WAL frames** (1,054,720 logical bytes). Suffixes of at most 16 frames used the +existing checkpoint buffer; larger suffixes rented a temporary buffer, returned +after sequential reads/writes completed. The logical chunk length stayed frame +aligned regardless of the pool's rounded capacity (up to 2 MiB). Database-copy +batch size, WAL format, commit durability and publication order were unchanged. +The candidate remained in an isolated Storage build throughout this experiment. + +Both Release builds completed with zero warnings/errors. **133 focused existing +WAL/checkpoint/fault tests passed** against the candidate. An isolated build of +the current checkpoint class passed **29 cases on each arm**, including the two +copied-prefix regressions and five added 255/256/512/513/4097-frame overlap, +commit-marker, reopen and subsequent-append cases. These extra size cases remain +in the evidence directory; production test source was not changed by this phase. + +The original uninstrumented harness and dependencies were identical except for +Storage. Two 80-commit smokes preceded the fixed ABBA screen: each measured slot +ran 300 durable commits of 10,000 rows, with 16 warmups and final drain. All six +exact count/checksum durable reopens passed. The desktop remained in normal use; +no measured workload was retried, excluded or added after the screen failed. + +| Pair | Baseline P99 | Candidate P99 | P99 change | Throughput incl. drain change | Allocation change | +|---|---:|---:|---:|---:|---:| +| Baseline then candidate | 36.7658 ms | 35.3086 ms | -3.96% | -0.018% | +1.381% | +| Candidate then baseline | 36.9697 ms | 34.5841 ms | -6.45% | -2.402% | +1.387% | + +**Reject the candidate.** Neither pair met the required 10% whole-operation P99 +improvement; both exceeded the 1% allocation limit, and median paired throughput +fell with and without drain. P95 stayed within its 5% limit. The planned smaller +workload guards and full candidate qualification were skipped under the stop rule. +The measured allocation increase was about 20.3 KB/commit; this screen did not +attribute allocations or individual compaction phases to a particular cause. + +Together with the earlier rejected 128-frame and prefetch candidates, this does +not support further buffer-size tuning as the next optimization. Select a new +target from end-to-end phase evidence before changing checkpoint scheduling or +the WAL format. The expensive mirrored-tail protocol remains parked. No product +source or qualified DLL changed; prior regression edits and unrelated files were +preserved. Evidence: `.tmp/wal-compaction-batching-20261006`. + +## October 6, 2026 Committed WAL Tail: Recovery Pass, Commit-Cost Rejection + +An opt-in [committed-tail extension](../../docs/design/wal-reclamation.md) resolves +the five torn-write recovery refusals from the preceding isolated page-store +experiment. Each commit flushes its frames, then separately writes/flushes both +control records before returning success. Recovery can discard bytes beyond +that proven tail while still rejecting damaged acknowledged data. The option +uses feature bit 0 and a checksummed field at byte 96; it is fresh-file-only and +disabled by default. Production source and format remain unchanged. + +The final Release build had zero warnings/errors and **179 tests passed**. The +new option covered **41 process-crash cases / 47 actual terminations**, including +all five previously refused torn-write points and six twice-interrupted reset +recoveries. The retained baseline tests bring the full-suite total to 90 child +terminations. A 369-case control-publication matrix returned the prior state +148 times and the new state 157 times, and rejected 64 cases with no readable +control after combined tear/slot-loss injection. Guards reject corruption +inside a declared commit, prevent rollback once boundary publication begins, +and retain acknowledged data after either control slot is lost. + +**The straightforward publication protocol failed its fixed commit-cost screen.** +It raises required WAL flushes from one to three and writes 8 KiB of additional +control data per commit. The ABBA screen used identical Release code, a fresh +file per slot, 40 separate-file warmups and 500 measured one-page commits. Each +slot included a full drain and exact durable reopen, all four of which passed. +There were no host-idle gates or automatic reruns. + +| Pair | Baseline P99 | Candidate P99 | Baseline commits/s incl. drain | Candidate commits/s incl. drain | +|---|---:|---:|---:|---:| +| Baseline then candidate | 3.8555 ms | 13.2950 ms | 451.88 | 141.74 | +| Candidate then baseline | 4.0372 ms | 10.5400 ms | 446.05 | 150.68 | + +P99 rose 2.61–3.45x, throughput fell 66.2–68.6%, and allocations increased by +about 9.2 KB/commit. This is an isolated page-store cost screen, not a production +engine or sustained reclamation benchmark. The loop stayed below its 64 MiB +physical cap. Both variants are experimental; these results do not change any +published product performance figure. + +Keep the mirrored-tail protocol as an opt-in correctness reference and defer +Pager adoption. The next performance step is format-preserving compaction I/O +batching. A later WAL-format proposal must protect commit boundaries more cheaply +before engine integration and full qualification. Evidence is preserved in +`.tmp/wal-commit-boundary-20261006`; all eleven qualified product DLLs, previous +regression edits, prior sealed evidence and unrelated files were verified unchanged. + +## October 5, 2026 Isolated WAL Page Store and Process-Crash Qualification + +The [experimental WAL design](../../docs/design/wal-reclamation.md) now has a +file-backed page store using the existing frame layout and additive checksum, +whole-batch commit/replay, physical-offset prefix retirement, conservative reader +pins and bounded generation reset. It links unchanged checksum source into the +isolated tests/worker; production projects and DLLs were not rebuilt or modified. +The production Pager, SQL engine and format consumers are not integrated yet. + +The final Release build had zero warnings/errors and **121 tests passed**: +30 codec, 21 control-protocol, 33 storage and 37 process-crash cases. The earlier +3,831 modeled control fault images remain covered. The new tests performed +**43 actual child-process terminations**, including six interruptions during +recovery of an already interrupted reset. Twelve subsequent slot-loss checks +preserved a new acknowledged commit after recovery. Workers recorded their +reached point and PID and killed only themselves; the OS remained running. + +**Five mid-write crash cases intentionally reject ambiguous torn tails without +changing either input file.** They do not count as successful automatic recovery. +The other 26 first-interruption cases and all six interrupted-recovery cases +reopened with the specified state. Complete validated uncommitted batches are +discarded before the next append; invalid active frames are never silently +trimmed away. The availability tradeoff must be resolved before adoption. + +Storage checks include both invalid retired-prefix cases with a later commit, +page-count growth, cancellation rollback, commit-marker buffer ownership, +snapshot stability under a concurrent writer, oversize refusal and Busy before +an append that would require reset while readers remain pinned. A 24-commit +growth test crossed its four-frame budget through 11 resets and reopened after +every commit; WAL extent never exceeded **24,672 bytes**. This is bounded +correctness evidence, not a sustained-load or latency measurement. + +The initial focused 68-test pass, final full isolated run, child markers and +pre-recovery files are preserved in `.tmp/wal-storage-prototype-20261005`. +All eleven qualified product DLLs, production source, previous regression edits +and unrelated changes were verified unchanged. No production suite or timing +workload was rerun. Next gates are actual Pager/reader integration, torn-tail +recovery policy, realistic growth, group commit and format-consumer compatibility. + +## October 5, 2026 Isolated WAL Control Codec and Fault Injection + +The [WAL design](../../docs/design/wal-reclamation.md) now has an isolated C# +control-record codec and file-backed publication/reset harness. The new +`CSharpDB.WalPrototype.Tests` project has no production references or solution +entry. It specifies the little-endian two-slot layout, validates checksums and +conflicts without unsafe fallback, and prevents counter wrap. Production +storage code, format and all eleven qualified product DLLs remain unchanged. + +The final Release build had no warnings or errors; **51 tests passed**. Within +that suite, **3,831 file-backed interruption cases** produced 2,747 exact page +recoveries and 1,084 expected fail-closed results. The failures represent +unreadable controls or deliberately corrupt active fixture frames, not silent +loss accepted by the test. Eighteen post-admission append cases preserved the +new page after either control slot was lost. Separate checks cover every slot +bit, every prefix/suffix tear boundary, sector subsets, unsupported checksummed +fields, reset I/O exceptions and four negative ordering controls. + +Review added an explicit restart flush: matching records read after a process +crash may still be unflushed OS-cache contents, so recovery establishes their +durability before destructive work or writer admission. Repairing a missing or +stale peer includes that flush. The initial 50-test pass and source remain +preserved beside the final 51-test run in `.tmp/wal-control-codec-20261005`. + +The harness performs real writes/flushes but materializes modeled interrupted +images for reopen; it does not kill a process or simulate actual power loss. +Its page fixture uses whole committed records and known checkpoint copies, +without the production frame parser, reader locks or allocation budget. The +next step is a fresh-file, opt-in storage integration followed by partial-tail, +commit, crash and reader-isolation tests. No performance workload or production +suite was rerun, and this phase establishes no latency improvement. + +## October 5, 2026 WAL Boundary and Reclamation Design Model + +A [draft recovery-boundary and reclamation design](../../docs/design/wal-reclamation.md) +now specifies two checksummed control records, mirrored durable floor updates, +an EMPTY/ACTIVE generation-reset protocol, physical-byte reservations and a +bounded quiescent full drain. It proposes a distinct WAL magic and fresh-file +opt-in prototyping. Production source, format and build outputs remain unchanged. +The design explicitly includes the additional metadata flushes and full-drain +pauses in future performance qualification; it does not claim a speedup. + +The accompanying [abstract model](../../docs/design/wal_boundary_model.py) passed +1,740 interruption/slot-failure cases: 1,445 returned the expected state and 295 +failed closed as specified. Four incorrect ordering controls exposed the +expected recovery failures. An eight-step admission model reached 641 states, +checked 1,791 transitions and refused 358 reservations; two negative controls +demonstrated over-budget accounting when retired bytes or pending reservations +are ignored. These are bounded model checks, not engine or hardware crash tests. +Frame encoding, partial commits, actual I/O, concurrency and migration remain +separate qualification work. + +A fresh three-input probe against the unchanged file-backed WAL reader opened +a normal version-one header and version 77 under `CWAL`, but rejected the +proposed `CWA2` magic. All three input hashes remained unchanged. This confirms +that a version-field bump alone is insufficient for that reader and that its +magic guard rejects the proposed marker; no complete new-format codec or +all-release compatibility claim follows from the probe. + +The next implementation step is an isolated control-record codec and file-backed +fault-injection harness before engine integration or timing. Existing recovery +regressions, all eleven qualified product DLLs and unrelated files were verified +unchanged. No production suite or performance workload was rerun. Evidence is +sealed under `.tmp/wal-boundary-design-20261005`; the first arithmetic-only +admission check is retained there, and the final model adds explicit state +transitions for reservation, publication, rollback, retirement, pins and reset. + +## October 5, 2026 Deferred WAL Relocation Feasibility and Recovery Guards + +**Simply retiring checkpointed frames from the live index while leaving their +bytes in place is not safe enough to benchmark.** An isolated logical-retirement +model lost a newer committed page on reopen in two deliberately corrupted-prefix +cases. The current production implementation preserved the page in both cases. +This finding concerns the experimental shortcut, not data loss in current code. + +The model kept physical frame offsets and the published physical tail intact, +removed only suffix relocation/truncation/header-flush work, and retired the +copied prefix from the latest-page map. All 143 Storage source files were checked; +only the isolated compaction method changed. No recovery-format or reclamation +policy was added. A fixed matrix ran four cases on each arm using fresh file-backed +WAL and base files. Both files were flushed/closed and reopened; their pre-reopen +copies were preserved because recovery can truncate the WAL. + +| Case | Current implementation | Logical retirement model | +|---|---|---| +| Valid prefix and later committed suffix | Exact pages survive reopen | Exact pages survive reopen | +| Invalid indexed prefix frame, repaired during checkpoint | Later committed page survives | Recovery stops at old prefix; later page is missing | +| Invalid superseded prefix frame, absent from latest-page validation | Later committed page survives | Recovery stops at old prefix; later page is missing | +| Twelve checkpoint generations, one live frame after each | WAL stays at 4,152 bytes | WAL grows to 57,712 bytes | + +The growth case reopened with one recovered frame on baseline versus fourteen +on the model. Exact page contents remained correct in this valid-prefix case, +and an idle final drain reset both WALs to the 32-byte header. This is a bounded +demonstration of physical growth and extra replay, not a sustained-load limit or +a performance measurement. No timing screen was run against the unsafe model. + +Two new cases in `WalCheckpointBatchTests` now require the later commit to +survive reopening immediately after finalization, for both indexed and +superseded invalid prefix frames. All **24 checkpoint batch cases passed in +Release**. The repository test source was compiled in an isolated test project +using the existing test defaults and the unchanged qualified Storage/Primitives +DLLs. Production projects and their build outputs were not rebuilt or modified; +this is focused regression evidence, not a fresh full-suite result. + +The source review identifies the requirements for a future safe prototype: + +- Recovery needs a trusted, durable boundary for obsolete bytes, or an + equivalently justified scheme. A flag set only when checkpoint repair runs is + insufficient: superseded bad frames can be absent from that validation. + Boundary publication must follow the durable database copy, be tied to its WAL + generation, and remain valid across interruption, rollback and later appends. +- Compatibility must fail safely. File and memory WAL recovery check the magic + and read salts but do not reject an unfamiliar version number before scanning. + Changing the version field alone would therefore not keep those older readers + from interpreting a new boundary scheme incorrectly. Diagnostic replay also + has its own version-one reader and needs explicit treatment. +- Separate live-frame counts, published physical tail, obsolete-prefix bytes + and allocated file bytes. `ReplaceCommittedState` currently computes the + published tail from compacted frame count. The pager's active-reader growth + check also estimates bytes from live frame count, so it cannot bound a retained + physical prefix. Preallocation is not a reclamation limit. +- Specify a physical growth budget and the action at that budget, including a + large in-flight commit and readers that prevent reclamation. Preserve snapshot + offsets and base-page visibility while copying, publishing and reclaiming; + retaining current writer/reader barriers is the conservative starting point. +- Prove recovery, format compatibility, reader isolation and bounded growth + before timing. Later performance qualification must include P99, throughput, + allocation, maximum physical WAL extent and reopen/replay cost. Moving the same + pause to occasional reclamation is not automatically a latency improvement. + +No broader WAL design was adopted. All eleven qualified product DLLs, existing +test/crash-harness Storage copies, prior evidence and unrelated files remain +unchanged. The only repository changes from this phase are the two regression +cases and this record. Evidence is sealed under +`.tmp/wal-deferred-relocation-feasibility-20261005`: fixed protocol, frozen inputs, +minimal model, raw before/after files, exact page hashes, growth observations, +isolated regression build/test output and closeout. + +## October 5, 2026 Rejected Retained-WAL Compaction Prefetch + +An isolated prototype overlapped the next 16-frame read with the current +compaction write. It **failed the unchanged paired performance screen** and +was never installed in production source or build outputs. The first pair's +transaction P99 improved only **1.81%**, below the required 10% in both pairs. + +The candidate used one temporary pooled second buffer, retained the existing +chunk size, and preserved write order and the truncate/header/flush/index +sequence. A failed write or metadata check joined any pending read before +returning the buffer. Scope verification checked all 143 Storage source files; +only the retained-compaction loop differed. The isolated assembly version was +4.6.5.0 and all other runtime dependencies matched the baseline. + +Before timing, **133 Release WAL, checkpoint and transactional fault-recovery +tests passed** against an isolated test runtime containing the candidate DLL. +This includes retained suffixes across move boundaries, overlapping source and +destination ranges, partial final chunks, multiple commits and durable reopen. +Both 80-commit smokes then passed exact durable count/sum checks for 960,000 +rows. Four fresh uninstrumented processes used baseline/candidate/candidate/ +baseline order, 10,000 rows per commit, 16 warmup and 300 measured commits. +Every measured process passed exact durable-reopen checks for 3,160,000 rows. + +| Slot | Arm | Transaction P99 | Begin P99 | Rows/s | Allocation/commit | +|---|---|---:|---:|---:|---:| +| 0 | Baseline | 38.92 ms | 28.84 ms | 810,477 | 1,468,611 B | +| 1 | Prefetch | 38.22 ms | 27.70 ms | 810,843 | 1,469,956 B | +| 2 | Prefetch | 39.22 ms | 28.66 ms | 808,650 | 1,469,927 B | +| 3 | Baseline | 220.13 ms | 206.61 ms | 538,115 | 1,468,628 B | + +Paired transaction-P99 changes were **-1.81% / -82.18%**, throughput changes +**+0.05% / +50.27%**, P95 changes **+1.28% / -4.46%**, and allocation changes +**+0.092% / +0.088%**. The final baseline had nine transaction starts above +100 ms, at iterations 51, 82, 113, 144, 175, 206, 237, 268 and 299; the longest +begin was **218.17 ms**. The other three processes had no starts above 100 ms. +All four had the same **11,511,312-byte WAL before drain**. + +The slow baseline is retained in full. Its recurrence confirms that the extreme +tail can occur on unchanged production code, but this uninstrumented screen +does not locate the cause. The second pair's apparent gain cannot compensate +for the first pair missing the predeclared threshold. No run was excluded, +retried or extended, and no prefetch depth or buffer size was retuned. + +The failed screen stopped medium/single-row guards, deterministic prefetch +failure-cleanup qualification and full engine/ADO/crash qualification. The +focused checks are therefore not a claim of complete candidate qualification. +All eleven qualified production DLLs, test/crash-harness Storage copies, prior +changes and unrelated files were verified unchanged. Only this record is kept +as a repository change from the experiment. + +Further small compaction-loop tuning has not earned adoption. Avoiding suffix +relocation is a separate design question: `WalIndex.ReplaceCommittedState` +currently derives the published physical tail from the compacted frame count; +checkpoint repair and recovery can scan the old physical prefix; and leaving +that prefix in place needs a WAL growth/reclamation bound under sustained +writes. Those invariants must be specified before testing deferred relocation. +This source review is a follow-up direction, not a validated optimization. + +Evidence: `.tmp/wal-compaction-prefetch-20261005` contains the isolated prototype, +source-scope check, focused test XML, fixed protocol, frozen inputs, six raw +process results, paired analysis and sealed closeout. + +## October 5, 2026 Allocation-Free Compaction Recorder Follow-Up + +The accepted query/storage work and previous investigation history were committed +as `3156badc`. A subsequent isolated diagnostic phase replaced the earlier +per-event objects and concurrent queue with a preallocated 32,768-slot struct +buffer and reduced method scopes from 19 to 7. No production source or product +binary changed. Removing instrumentation reconstructed all 143 source files. + +Before database measurement, the recorder passed a 20,000-event check with +**zero bytes allocated on the recording thread**, an eight-producer check with +16,000 unique complete records, and an explicit overflow check. The buffer was +allocated, touched and warmed before the workload. Export occurred after the +checkpoint drain. This establishes the checked recorder's allocation behavior; +it does not establish zero instrumentation overhead or zero workload allocation. + +After one 80-commit smoke, exactly four fresh baseline processes each ran 16 +warmup and 300 measured commits of 10,000 rows on the normal desktop. All five +processes passed exact durable-reopen count/sum checks. Each measured capture +contained 6,239 records with zero drops and nine compactions. Byte/offset and +sequential-phase validation accounted for 6,012 reads and 6,012 writes, each +direction totaling 394,976,160 bytes across the 36 measured compactions. + +| Capture | Begin P99 | Maximum begin | Median compaction | +|---|---:|---:|---:| +| 0 | 27.89 ms | 42.53 ms | 17.36 ms | +| 1 | 28.25 ms | 39.14 ms | 18.00 ms | +| 2 | 27.05 ms | 39.41 ms | 17.24 ms | +| 3 | 26.50 ms | 37.95 ms | 16.89 ms | + +**No begin exceeded the predeclared 100 ms threshold.** The earlier roughly +180 ms pause remains unexplained. No capture was retried, excluded or added +after this result. These captures were not paired with uninstrumented controls; +their lower timings relative to earlier sessions cannot be assigned to recorder +changes or presented as a production performance improvement. + +Per-process medians for one compaction were 4.96–5.28 ms in data reads, +5.39–5.59 ms in data writes, 1.16–1.21 ms in truncation, and 5.12–5.43 ms in +WAL durability flush. These are independent medians, not an additive breakdown +of one operation. The ordinary retained-suffix relocation cost remains visible; +the uncaptured extreme tail still has no device, filesystem, scheduling or GC +attribution. Further repeated captures of this workload are not currently +justified by a reproducible trigger. A future tail investigation should start +from such a trigger; the repeatable compaction cost can be considered separately +without assuming that the previously rejected buffer/budget/flush changes work. + +All eleven qualified product DLLs and the engine/ADO/crash-harness Storage copies +remained unchanged. Existing Release regression evidence therefore remains +applicable; this phase adds the recorder checks and five durable reopens, rather +than rerunning production tests against identical binaries. + +Evidence: `.tmp/wal-compaction-low-overhead-20261005` holds the isolated source, +recorder self-check, fixed protocol, frozen inputs, raw captures, complete byte +validation, analysis and sealed closeout. Only this history entry is retained +as a repository change after the commit. + +## October 5, 2026 Retained-WAL Compaction I/O Attribution + +Four fixed diagnostic baseline captures completed with exact I/O accounting and +durable-reopen checks. **None reproduced a transaction-start pause above the +predeclared 100 ms threshold**; the longest was **77.00 ms**. This investigation +therefore does not explain the earlier roughly 180 ms tail. No extra capture was +added to chase that event, and no production optimization was adopted. + +An isolated copy of current Storage added timings around each retained-suffix +read and write, including call start, return and post-await completion. Separate +scopes measured metadata processing, file truncation, WAL-header rewrite, +durability flush and index publication. Removing the diagnostic additions +reconstructed all 143 original source files exactly. Assembly version and +dependencies matched production; source and product build outputs were untouched. + +After an 80-commit smoke passed, four fresh processes each ran the historical +10,000-row durable batch workload with 16 warmup and 300 measured commits on the +normal desktop. Each process passed exact count/sum verification after reopening +3,160,000 rows. The smoke reopened 960,000 rows. Every captured compaction passed +checks for unique records, monotonic timing, sequential/nonoverlapping child +phases, contiguous source/destination offsets and exact byte totals. + +The 36 measured compactions each moved about **10.46 MiB in 167 read/write +pairs**. In total, **6,012 data reads and 6,012 data writes** each accounted for +**394,976,160 bytes**. Per-process medians of the aggregate phase cost for one +compaction were: + +| Capture | Data reads | Data writes | Truncate | WAL flush | Total compaction | +|---|---:|---:|---:|---:|---:| +| 0 | 10.26 ms | 10.33 ms | 3.30 ms | 8.12 ms | 33.73 ms | +| 1 | 10.54 ms | 10.30 ms | 3.22 ms | 9.18 ms | 34.09 ms | +| 2 | 9.59 ms | 9.07 ms | 3.00 ms | 7.85 ms | 30.61 ms | +| 3 | 10.33 ms | 10.11 ms | 3.50 ms | 10.12 ms | 33.82 ms | + +Each column is an independent median; the table is not an additive decomposition +of one selected operation. Metadata processing medians were **0.25–0.30 ms**, +index publication **0.046–0.055 ms**, and header rewrite **0.14–0.28 ms**. The +first header rewrite in each process was slower (**4.84–8.77 ms**); the capture +does not establish whether JIT or another startup effect caused that difference. + +For data I/O, median aggregate call-dispatch time was **7.30–8.36 ms for reads** +and **7.14–8.01 ms for writes**, versus roughly **2.18–2.57 ms** after return until +the awaited continuation resumed. Only 7 reads and 8 writes were already complete +at the immediately-before-await check. The longest individual data-read span was +**2.64 ms** and data-write span **1.69 ms**. These numbers locate the ordinary +cost in repeated I/O calls and durability work, rather than metadata/index loops. +They do not distinguish native I/O, filesystem/device behavior, scheduling or GC. + +Instrumented transaction-start P99 was **48.30–53.33 ms** and maximum +**69.59–77.00 ms** across the four processes. Diagnostic instrumentation changes +execution and can change scheduling; its timing and allocation numbers are not +an end-to-end performance comparison. The small interval not covered by child +scopes is not a bound on total instrumentation overhead. Capturing the extreme +baseline event with less intrusive recording remains necessary before assigning +its cause or selecting another tail-latency fix. + +All eleven production DLLs, engine/ADO/crash-harness Storage copies, earlier +source changes and unrelated files were verified unchanged. No production test +rerun was needed for this isolated diagnostic phase; fresh validation consists +of the five durable reopens and complete compaction-byte/offset checks. + +Evidence: `.tmp/wal-compaction-io-20261005` contains verified instrumented source, +the frozen four-capture protocol and inputs, raw phase records, byte-accounting +validation, analysis and closeout. No measured workload was retried or excluded. + +## October 5, 2026 Rejected Background Checkpoint Catch-Up Budget + +An isolated prototype doubled the copy budget before admitting a waiting +large-commit writer. It failed the paired performance screen and was never +installed in the repository's production sources or build outputs. The previous +storage/query improvements and six retained compaction regression cases are +unchanged. Only this record is retained from the experiment. + +Reanalysis of both prior current-runtime phase captures found **63 copy slices +and 20 intervening transaction starts per checkpoint generation**, leaving +**2,662–2,665 retained WAL frames (about 10.46 MiB)** to relocate. That supported +testing a larger per-turn budget: copy twice the latest large commit's page +count before yielding. Each individual copy slice, small-commit full draining, +reader/writer barriers, flushes and WAL format stayed unchanged. The prototype +changed only that budget expression and its comment in a separate Storage build. + +Both arms first passed 80-commit smoke runs with exact durable-reopen count/sum +checks for 960,000 rows each. The frozen screen then used the same historical +uninstrumented harness, all identical dependencies except Storage, background +checkpoint threshold 4,096, 10,000 rows per commit, 16 warmup and 300 measured +commits, in baseline/candidate/candidate/baseline order. All four processes +passed exact durable-reopen checks for 3,160,000 rows each. Runs used the normal +desktop, with no idle gates, retries, exclusions or tuning of the multiplier. + +| Pair | Baseline P95 | Candidate P95 | P95 change | Baseline P99 | Candidate P99 | Throughput change | +|---|---:|---:|---:|---:|---:|---:| +| 1 | 23.33 ms | 27.17 ms | +16.45% | 46.60 ms | 57.48 ms | -6.92% | +| 2 | 20.71 ms | 28.64 ms | +38.30% | 181.25 ms | 51.85 ms | +10.78% | + +Both P95 comparisons exceeded the +5% guard, and the required at-least-10% P99 +improvement in both pairs failed. Allocation fell about 0.31% and background +task starts fell from 189 to 117, but those narrower gains did not qualify the +change. The planned 1,000-row and single-row guard workloads and production +promotion were skipped under the predeclared stop rule. + +The last **unchanged baseline** process reproduced the large periodic tail: +transaction-start P99 was **167.74 ms**, maximum **198.69 ms**, with its slowest +starts at the same checkpoint-finalization cadence seen earlier. This establishes +that a roughly 180 ms whole-operation tail can occur without either rejected +WAL optimization. It does not establish filesystem, scheduler, device or other +host activity as its cause. The new screen records whole Begin/Insert/Commit +phases; it cannot attribute that particular spike to a lower-level I/O operation. +The next diagnostic target is read/write/truncate/flush attribution within +retained-WAL compaction during a baseline spike. + +All eleven production DLLs, the engine/ADO/crash-harness Storage copies, previous +source changes and unrelated files were verified unchanged. No new production +tests were added or rerun for this isolated rejection; the preceding **138-test +Release qualification** still identifies these same production binaries. The +six new benchmark processes supplied fresh durable-reopen correctness checks. + +Evidence: `.tmp/checkpoint-catchup-budget-20261005` contains the original +repository hashes, isolated source/runtime, frozen protocol and inputs, all +smoke and comparison outputs, phase analysis, rejected patch and closeout. + +## October 5, 2026 Writer-Wait Attribution and Retained-WAL Compaction Screen + +The recurring background-checkpoint stalls now have direct phase attribution: +the next transaction waits while checkpoint finalization compacts the newer WAL +suffix left by commits interleaved with the earlier copy. Larger move chunks +were tested and rejected under the predeclared performance screen. Production +sources and all eleven product DLLs were restored to their preceding state; +six boundary/reopen regression cases and this investigation record are retained. +The earlier storage safety and primary-key range changes remain intact. + +An isolated Storage build inserted scoped, thread-safe timing records around +writer admission, checkpoint preparation/validation/copy, finalization, +database/WAL flushes, compaction/truncation and task lifetime. Four serial fresh +processes ran current/rejected-flush-once/rejected-flush-once/current with the +same historical 10,000-row durable batch harness: 16 warmup and 300 measured +commits each. All four passed exact durable-reopen count and sum checks for +3,160,000 rows. Instrumentation is diagnostic evidence, not performance +qualification or a replacement for the earlier rejected measurements. + +On current code, representative recurring transaction-start waits of +**30–41 ms** overlapped retained-WAL compaction for **19–26 ms**. Writer-wait +spans nearly covered those transaction-start intervals; acquiring the checkpoint +barrier took at most about 0.1 ms in those examples. Database flushing contributed +about 8–9 ms, and WAL flushing inside compaction about 5–6 ms. These are nested +scopes and must not be added indiscriminately. First-copy admission also showed +roughly 10–11 ms of committed-WAL validation. The earlier approximately 193 ms +whole-operation outlier did not recur, so its specific cause remains unresolved. + +The candidate used a temporary pooled buffer capped at **128 frame-aligned +frames** for retained-suffix moves, instead of reusing the 16-frame checkpoint +buffer for all moves. Small suffixes retained the existing buffer. Database +copy/validation slices, WAL format, durability barriers and defaults were unchanged. +Six new cases cover 16, 17, 127, 128, 129 and 257 retained frames, overlapping +source/destination ranges, multiple commits, replacement pages, partial final +chunks, reopen recovery and appending after relocation. **131 focused Release +WAL/checkpoint tests passed** before the uninstrumented screen. + +The paired screen used the original byte-identical timing harness and dependencies, +changing only Storage, in baseline/candidate/candidate/baseline order on the normal +desktop. Each process measured 300 durable commits of 10,000 rows and passed +the full count/sum reopen check. No measured process was retried or excluded. + +| Pair | Baseline P99 | Candidate P99 | P99 change | Throughput change | P95 change | Allocation change | +|---|---:|---:|---:|---:|---:|---:| +| 1 | 38.37 ms | 35.99 ms | -6.18% | -1.98% | +4.65% | +0.91% | +| 2 | 38.12 ms | 33.82 ms | -11.29% | +0.81% | +2.26% | +0.92% | + +Transaction-start P99 improved 11.53% and 14.21%, but the required whole-operation +P99 improvement of at least 10% in **both** pairs failed, as did non-negative +median paired throughput (about -0.59%; -0.63% including final drain). The planned +1,000-row and single-row guard workloads were skipped under the stop rule. +The buffer change was therefore restored, not promoted on the narrower phase gain. +The next investigation should examine the amount of retained WAL that accumulates +while copy slices and writers alternate, before changing the scheduling policy +or relocation algorithm. + +The first new-test attempt used the wrong expectation for the live index +generation: compaction advances it rather than resetting it to the retained +commit count. All six failures are preserved; the assertion was corrected before +measurement. Restoration also caught a stale incremental output after copying +back an older-timestamp source file. That test attempt is explicitly invalid for +restored-runtime qualification. A forced Storage rebuild, completed dependent +build, and hash checks established identical pre-experiment binaries, including +the engine-test, ADO-test and crash-harness Storage copies. The final **138 +Release WAL/checkpoint/fault/crash tests passed**, including the six new cases. + +Evidence: `.tmp/checkpoint-writer-wait-20261005-v2` contains the isolated phase +records and verified instrumentation. Its initial sibling preserves a Windows +path-normalization setup failure before any build or measurement. +`.tmp/wal-compaction-chunks-20261005` contains the frozen performance protocol, +runtime snapshots, all raw samples, failed and corrected test records, rejected +patch, final runtime verification and restoration closeout. + +## October 5, 2026 Checkpoint Attribution and Rejected Flush-Once Follow-Up + +No additional production optimization was retained from this investigation. +The earlier storage safety repairs and primary-key range mutation targeting +remain intact. A checkpoint flush-once candidate passed correctness tests but +failed the background large-batch performance screen again, so its WAL and test +changes were restored exactly to the pre-experiment sources. All results and +the rejected patch are preserved; no measured workload was retried or excluded. + +An isolated copy of the current Storage assembly added eight scoped timers and +ran four mixed-workload profiles: 8 MB and 84 MB, default and mapped reads, +8 warmup plus 64 measured cycles each. Every one of the 256 measured final +checkpoints issued **two database durability flushes and one WAL durability +flush**. The first database-flush median was **2.38–2.44 ms**, the second +**0.47–1.90 ms**, and WAL-flush median **2.09–2.10 ms**. Timer scopes are nested; +WAL reset and pager refresh include their child operations. These instrumented +runs had total checkpoint medians **7.70–9.52 ms** and are attribution evidence, +not a direct comparison with the preceding uninstrumented timings. All exact +query/snapshot/checkpoint and full durable-reopen checks passed. + +A distinct mapped read-only control compared the pre-range-targeting and current +runtimes using one byte-identical harness. Each size ran before/current/current/ +before, with 32 warmup and 256 measured cycles of 16 prepared point reads, two +256-row ranges and a held reader. All **36,864 timed queries** passed exact +results/EOF/cleanup checks, and database files were unchanged byte-for-byte. +Median changes were: + +| Fixture | Pair | Point read | Range read | +|---|---:|---:|---:| +| 8 MB | 1 | +3.93% | +1.05% | +| 8 MB | 2 | +63.00% | +76.29% | +| 84 MB | 1 | -7.36% | -14.67% | +| 84 MB | 2 | 0.00% | +0.98% | + +No size/metric exceeded the diagnostic +5% median or +10% P99 threshold in both +pairs, but the large small-file slowdown is retained. The result is inconclusive +about the earlier mapped mixed-workload misses: it does not establish a +consistent direct query-path regression, prove host noise, or clear those gates. +Mapping remains opt-in. + +The duplicate-flush experiment reused the previously rejected approach: record +successful database durability on each immutable checkpoint image, skip another +database flush during separate finalization, retry failed/cancelled flushes, and +require a new flush for a later checkpoint image. No WAL durability barrier or +default was changed. The current candidate passed **3,456 engine + 325 ADO = +3,781 Release tests**, including four successful/retry/retained-frame cases and +the process-crash suite with the crash harness rebuilt against that candidate. + +Before any mixed-workload qualification, the candidate faced the previously +failing background workload on current code: the identical historical harness +source, `UseLowLatencyDurableWritePreset(4096)`, 10,000 rows per commit, 16 warmup +and 300 measured commits, four serial fresh processes in baseline/candidate/ +candidate/baseline order. Timing mode avoids per-commit diagnostic reads. All +four processes passed durable-reopen row-count/sum verification for **3,160,000 +rows each**, including warmup. Performance results were: + +| Pair | Baseline rows/sec | Candidate rows/sec | Throughput change | Baseline P99 | Candidate P99 | P95 change | +|---|---:|---:|---:|---:|---:|---:| +| 1 | 810,237 | 564,078 | -30.38% | 38.54 ms | 192.71 ms | -0.49% | +| 2 | 753,809 | 749,158 | -0.62% | 41.39 ms | 41.86 ms | +5.42% | + +The required P95 improvement in both pairs, non-negative median paired throughput +change, and non-positive median paired P99 change failed. The planned 16 mixed +comparison processes were therefore skipped under the predeclared stop rule. +Neither this result nor the original October 3 rejection was replaced. + +Raw phase timings locate the large first-pair tail in **transaction start**: +its P99 rose **27.04 to 178.82 ms** and maximum **37.85 to 196.66 ms**, while +median row-insertion time remained about **7.0 ms**. This narrows the next +investigation to the background checkpoint's effect on writer admission; +it does not yet isolate coordinator scheduling, filesystem I/O, or host activity +as the cause. Inspect `Pager.WaitForBackgroundCheckpointForWriterAsync` alongside +background copy slices and finalization before adopting a flush reduction. + +After rejection, both experimental files were restored by verified hashes and +the engine, ADO and crash-harness outputs rebuilt. **157 storage/crash plus six +ADO focused tests passed** on the restored implementation. The prior accepted +production sources and unrelated work remain intact; only this history entry +is retained in the repository from this phase. + +Evidence: `.tmp/checkpoint-read-attribution-20261005-v2` contains 2,408 frozen +inputs, phase traces and read-only controls. The initial sibling directory +preserves an assembly-version setup failure that occurred before entering the +workload and produced no timing samples; corrected probes passed all four smoke +cases before measurement. `.tmp/checkpoint-flush-once-20261005` contains 2,244 +candidate inputs, source/runtime snapshots, complete test results, background +samples, phase analysis, rejection/restoration receipts and the rejected patch. + +## October 5, 2026 Primary-Key Range Mutation Targeting + +UPDATE and DELETE now consider the existing physical integer primary-key range +operator after their existing equality and secondary-index choices. The collector +still materializes stable row identities and full rows before applying changes; +residual predicates, index maintenance, transaction handling and logical read +tracking remain in the existing paths. The operator exposes its current physical +row id, and plain EXPLAIN uses the same fallback as execution. Composite and +backed primary keys retain their existing paths. Mapping and checkpoint defaults +are unchanged, and both comparison arms include the prior storage safety repairs. + +This removes the full-table scan previously observed for a 16-row UPDATE. +In the **84 MB / 100,000-row** default-read case, full transaction medians fell +from **122.50–127.70 ms to 2.62–2.71 ms**, a **97.79–97.95%** paired reduction. +Complete cycles fell from **135.30–141.08 ms to 11.97–12.42 ms**; process-wide +allocation fell from **134.38 MB to 2.385 MB/cycle** (decimal MB). Point-read P99 +fell from **142.49–150.67 ms to 2.36–2.38 ms**. These are the full mixed workload +measurements, including durable commits, not comparison-count or scan-only claims. + +Paired candidate/baseline median changes (negative is faster/lower): + +| Fixture / reads | Pair | Transaction | Final checkpoint | Complete cycle | Allocation/cycle | +|---|---:|---:|---:|---:|---:| +| 8 MB / default | 1 | -78.58% | -2.34% | -47.21% | -85.75% | +| 8 MB / default | 2 | -78.79% | -3.69% | -48.24% | -85.75% | +| 8 MB / mapped | 1 | -84.92% | -0.84% | -56.16% | -93.85% | +| 8 MB / mapped | 2 | -84.39% | -11.40% | -56.30% | -93.85% | +| 84 MB / default | 1 | -97.95% | -6.31% | -91.51% | -98.23% | +| 84 MB / default | 2 | -97.79% | -3.64% | -90.82% | -98.23% | +| 84 MB / mapped | 1 | -98.39% | -60.54% | -93.77% | -99.36% | +| 84 MB / mapped | 2 | -98.48% | -60.30% | -94.21% | -99.36% | + +All default-read comparisons passed the predeclared guards: at least 10% lower +transaction medians, no other guarded median or allocation regression above 5%, +and no repeated P99 regression above 10%. Mapped comparisons passed the write, +checkpoint, cycle and allocation guards but missed three read-median checks: +small pair 2 range **+5.48%** (400.65 to 422.60 microseconds), large pair 1 point +**+8.55%** (66.70 to 72.40 microseconds), and large pair 1 range **+8.69%** +(421.65 to 458.30 microseconds). No case had a repeated P99 regression. These +misses are retained without reruns or causal attribution; the complete mapped +performance gate is not passed. Keep mapping opt-in. + +The byte-identical harness from the preceding comparison ran 16 fresh serial +processes: baseline, candidate, candidate, baseline for each of two file sizes +and two read modes. Baseline is the corrected pre-change runtime; candidate +differs in three execution-source files and four rebuilt DLLs (Execution, Engine, +Client and Data). External dependencies, all other runtime DLLs, harness and +fixtures match. All **2,197 frozen inputs** verified. Each process ran 32 warmup +and 256 measured cycles, using the prior pooled ADO workload: 16 prepared point +reads and two 256-row ranges overlap a transaction that updates 16 rows and +appends four. A held reader retains its snapshot, the first checkpoint must +defer copying, and a final checkpoint after disposal drains the WAL. + +All **4,096 measured transactions, 73,728 ordinary queries, 4,096 held readers, +81,920 row mutations and 8,192 checkpoint requests** passed exact result, +snapshot, cleanup and full durable-reopen checks. Every process showed actual +query/transaction interval overlap (446–518 queries per process). All samples +were retained, with no measured retry, host-idle gate, or competing build/test. +This is normal-desktop warm evidence with unbounded caches, one reader worker +plus a held reader, and manually scheduled foreground checkpoints at an explicit +`int.MaxValue` automatic threshold. It does not qualify cold I/O, many-client +stress, product-default checkpoint scheduling or an entire release. Timed SQL +measures UPDATE; DELETE shares the collector and has correctness coverage, but +no separate DELETE timing claim is made. + +A separate eight-process, two-cycle diagnostic confirmed **eight baseline +scanned collections versus eight candidate indexed collections**. On the large +mapped case, read-only copied pages before checkpoint fell from **19,995 to 1**; +default writer cache size before checkpoint fell from **20,014 to 20 pages**. +The scan removal therefore also eliminates most copied-page invalidation work +in this workload, reducing the immediate value of a cache-retention redesign. +Remaining final-checkpoint medians were **5.20–5.33 ms default** and +**5.88–6.15 ms mapped** on the large file, compared with **2.03–2.09 ms** and +**2.23–2.29 ms** commit medians. Next investigate that remaining checkpoint cost +and the mapped short-read guard misses before changing cache lifetime or defaults. + +Final Release validation passed **3,452 engine + 325 ADO = 3,777 tests**, with +no failures or skips. The 43 new cases cover bounds, residual/OR/NULL semantics, +neighbor preservation, primary-key changes and secondary indexes, rollback, +extreme keys, backed/composite keys, empty-range serializable conflicts, +EXPLAIN/ANALYZE row identities, non-leading keys, ALTER ADD row shapes, and +prepared mutation rebinding. The pre-change baseline failed 22 bounded-path +assertions while passing their preceding result checks; this is optimization +coverage, not a claim of 22 existing data-correctness defects. + +Evidence: `.tmp/pk-range-mutation-20261005` retains baseline sources/runtime, +protocol, frozen inputs, all raw intervals and paired analysis, diagnostic +results, test/build logs and preservation receipts. The preceding 301 mixed-run +artifacts and unrelated work are preserved. + +## October 5, 2026 Mixed Read/Write and Checkpoint Comparison + +The corrected mapped option is **not qualified as a general default** by this +mixed workload. Its prepared range reads remained faster, but full transactions, +checkpoint completion, allocation, and point-read tails regressed. Keep the +correctness repairs and the opt-in setting. The next optimization target is +physical primary-key range targeting for UPDATE/DELETE, followed by retaining +stable copied pages across checkpoints where safe. + +The fixed sequence compared archived `26a55593` default reads, corrected default +reads, and corrected mapped reads. Each of two fixtures ran baseline, corrected, +mapped, mapped, corrected, baseline in six serial fresh processes. The fixtures +contained 10,000 and 100,000 real mixed-payload rows and occupied **8,441,856 and +84,164,608 bytes**. Seven rebuilt runtime DLLs differed between baseline and +corrected arms, reflecting the four recorded storage-source repairs and rebuilt +dependents; the common harness and external dependencies were identical. All +1,094 frozen inputs passed verification. No product source changed in this phase. + +Each process ran 32 warmup and 256 measured cycles. One primed 256-row reader +held its snapshot while a second pooled ADO worker ran 16 prepared point lookups +and two 256-row ranges concurrently with a durable transaction updating 16 rows +and appending four. A checkpoint request while the held reader was active had +to defer copying; a second request after disposal drained the WAL. The run used +unbounded caches, normal read-ahead, foreground mode, and an explicit +`int.MaxValue` auto-checkpoint threshold to isolate the scheduled manual +checkpoints. This is not a product-default auto-checkpoint or cold-disk test. + +All **12 processes, 3,072 measured transactions, 55,296 ordinary queries, 3,072 +held readers, 61,440 row mutations, and 6,144 checkpoint requests** completed +with exact row/version/order/EOF checks, correct checkpoint states, reader +cleanup, and full durable-reopen verification. Every process showed overlapping +query/transaction intervals. All samples were retained; there were no measured +reruns. Preparation smoke runs and a separate post-timing diagnostic are excluded +from performance comparisons. Process-wide allocation is reported per complete +cycle, not attributed to individual concurrent operations. + +Mapped versus corrected default, paired median changes: + +| Fixture | Range read | Full transaction | Final checkpoint | Complete cycle | Allocation/cycle | +|---|---:|---:|---:|---:|---:| +| 8 MB, pair 1 | -59.91% | +39.16% | +23.29% | +17.87% | +28.65% | +| 8 MB, pair 2 | -64.64% | +29.23% | +21.73% | +10.61% | +28.66% | +| 84 MB, pair 1 | -66.70% | +48.30% | +184.98% | +45.34% | +58.66% | +| 84 MB, pair 2 | -66.03% | +61.20% | +179.12% | +60.60% | +58.66% | + +On the larger file, corrected default transaction medians were **114.06–126.44 +ms**, versus **183.86–187.52 ms** mapped. Commit alone remained **2.38–2.75 ms** +default and about **2.38 ms** mapped. Checkpoint medians increased from about +**5.52 ms to 15.40–15.72 ms**. Mapped range reads took **0.419–0.429 ms** instead +of **1.232–1.287 ms**. Allocation rose from **134.38 to 213.22 MB/cycle** (decimal +MB). Point median latency improved, but its P99 rose **40.82% and 91.35%** on +the large fixture; a small median therefore hides the admission tail. The +mapped arm failed the predeclared non-regression guards in both fixtures. + +The correctness repairs with default reads passed all transaction, commit, +checkpoint, cycle and allocation median guards versus the old default, with no +repeated P99 failure. However, large-file pair 1 point median was **+6.68%** and +pair 2 range median **+6.25%**, exceeding the strict per-pair 5% limit. Consequently +the complete repair performance gate is also not fully passed. These local +results are retained without attributing every small read difference to the +repair or claiming release-wide performance neutrality. + +A separate four-case, two-cycle diagnostic confirmed two costs on corrected +code. Every 16-row range UPDATE used the scanned mutation-target path, with +zero indexed collections. `CollectMutationTargetRowsAsync` considers lookup and +secondary-index paths but omits the physical primary-key range path already +used by SELECT, so this predicate falls through to the full table scan. The +pool's read and write dispatch share a gate, allowing that work to delay new +read admission. This is source/counter attribution, not isolated phase timing. + +The diagnostic also observed **1,995 / 19,995 read-only copied pages** retained +before final checkpoint in the small/large mapped cases, then **zero** afterward. +On the large file the writer cache fell from 20,013 to 18 pages. Default reads +retained all 20,014 cached pages in the corresponding cycle. The safe managed +copies are currently treated like transient mapped views during checkpoint +invalidation, so the next full UPDATE scan repopulates them. This provides a +concrete cache-retention follow-up; it does not justify restoring unsafe borrowed +views or changing the default now. + +Evidence: `.tmp/mixed-read-write-20261005` contains the frozen protocol, runtimes, +fixtures, all raw intervals, paired analysis, diagnostics, and preservation +receipts. The 84 MB fixture is larger-file evidence, not a greater-than-2-GiB +database qualification. The previous 3,734 Release test result remains the +correctness validation for the unchanged production sources. + +## October 5, 2026 Mapped-Read Safety Qualification (Correctness Repairs) + +The follow-up to the read-only option comparison found and repaired two +correctness defects before further performance qualification: + +- With a one- or two-page cache, B-tree structural edits could retain a clean + parent or sibling buffer across eviction, then mark a reloaded old buffer + dirty. This lost routing/link updates during insertion, replacement, and + deletion. A pooled growing-write case returned every row but in the wrong + order, and a point lookup missed an existing row even after reopening. + B-tree mutations now register the exact edited buffer; clean traversal pages + are not marked dirty merely because they were visited. +- A shared writer-pager mapping could be disposed by checkpoint growth while + a snapshot point lookup or explicit transaction clone still used its page. + Deterministic gated tests reproduced `ObjectDisposedException`. Shared mapped + reads now return stable read-only managed copies, with copying synchronized + against remapping/disposal. Dedicated snapshot pagers retain zero-copy mapped + views, and the mapping option remains opt-in. + +The diagnostic preserved a fixed 12-case baseline matrix: mapping off/on, +cache sizes 2/16/default, and read-ahead off/on. All four cache-2 cases had +incorrect ordering and a missing point lookup; the eight larger-cache cases +passed. The problem already existed before commit and survived checkpoint and +reopen, excluding read-ahead and mapping as its cause. No rows were absent from +the full scan, but B-tree routing and ordering were incorrect. + +Against archived unchanged baseline production assemblies, **14 of 16 targeted +regressions failed**: twelve bounded-cache structural cases and both mapped +checkpoint-overlap cases. The two ordinary-read overlap controls passed. +The final regression set adds **31 cases** covering those failures, pooled +commit/rollback and prepared-command reuse, held snapshot scans, independent +reader disposal, retained text/blob values, remapping, partial-page reads, +supported shrink after releasing mappings, and sparse growth beyond 2 GiB. +The sparse test writes only 8 KiB; it verifies provider fallback correctness, +not large-database performance. + +The fixes passed clean Release builds, **180 focused tests**, and the full +Release engine/ADO suites: **3,411 + 323 = 3,734 passed**, with no failures or +skips. Independent review found no blocker in either correction. One initial +delete-test assertion incorrectly required a three-level tree to compact to a +single leaf despite the existing deliberate retention of underfull interior +levels; the archived failing test was corrected to a two-level fixture that +exercises supported root collapse, retaining all row/payload/lookup checks. +All initial failures and baseline diagnostics are retained under +`.tmp/mapped-read-safety-20261005`. + +This is correctness evidence, not a new performance claim. Shared mapped cache +misses now copy a 4 KiB page and may copy again on later mutable access. The +earlier read-only numbers must not be generalized to shared point lookups, +direct Database reads, or write workloads after this repair. Next measure +mixed read/write latency, checkpoint tails, and larger real databases using +the corrected implementation before considering broader default behavior. + +## October 5, 2026 Existing Memory-Mapped Read Option (Local Read Improvement) + +The existing `UseMemoryMappedReads()` option passed the fixed four-workload +comparison on baseline `26a55593`. The wide mixed-column query fell from about +**11 ms to 2.33 ms**, with about **49.2% less allocation**. Small queries also +improved with per-reader mapping creation and disposal included. Production +defaults and code were not changed. + +Both arms supplied explicit `DatabaseOptions` to the pooled ADO connection; +only the `UseMemoryMappedReads` Boolean differed. They used one common harness +and identical copies of all 18 frozen runtime DLLs. First-warmup inspection +verified featureless execution, the expected row/batch projection path, native +providers, the inspected reader's dedicated live mapping, and mapping release +after awaited reader disposal. Runtime counters, tracing, interceptors, and +measured-query reflection were absent. + +The fixed ABBA sequence used **16 serial fresh processes, 40,000 measured queries, +and 13,520 warmups**, with all samples retained. Every query checked both integer +values, exact row count, EOF, and reader cleanup. All 62 frozen inputs and +fixture copies passed verification. No measured workload was retried or excluded. +Release builds were clean and **49 existing focused tests passed**: page buffers, +storage configuration, ADO tuning, mapped views/owned copies, and fallback reads. + +| Workload | Pair 1 median, default to mapped | Pair 2 median, default to mapped | Allocation reduction, pairs 1 / 2 | +|---|---:|---:|---:| +| Numeric, 4 rows | 39.2 to 30.3 us (-22.70%) | 43.7 to 30.8 us (-29.52%) | 62.13% / 62.13% | +| Numeric, 4,096 rows | 741.7 to 564.5 us (-23.89%) | 739.1 to 570.2 us (-22.85%) | 89.81% / 89.80% | +| Mixed, 4 rows | 66.2 to 50.6 us (-23.56%) | 65.7 to 55.5 us (-15.53%) | 64.60% / 64.59% | +| Mixed, 4,096 rows | 11,003.8 to 2,342.0 us (-78.72%) | 10,863.3 to 2,328.5 us (-78.57%) | 49.23% / 49.24% | + +Both wide mixed-query pairs exceeded the predeclared requirements of 10% lower +median latency and 20% lower allocation. Every per-pair 5% median/allocation +regression guard and repeated 10% P99 guard passed; P99 decreased in all eight +pairs. These are local configuration-comparison results, not release-wide +qualification or a default-setting change. Independent review recomputed all +40,000 raw samples and eight paired comparisons, confirming every result and +the absence of regression flags. + +The option used in the mapped arm is already available: + +```csharp +var options = new DatabaseOptions() + .ConfigureStorageEngine(builder => builder.UseMemoryMappedReads()); + +await using var connection = new CSharpDbConnection( + "Data Source=app.db;Pooling=true", options); +await connection.OpenAsync(); +``` + +This comparison used immutable checkpointed 10,000-row files of 520,192 and +8,441,856 bytes on one Windows/.NET 10.0.12 host. It includes mapped reader setup, +but does not exercise concurrent writes or held readers across checkpoints. +The provider also falls back to copy-based reads when file length exceeds its +`int.MaxValue` mapping limit. The next qualification step is mixed read/write, +reader lifetime/checkpoint behavior, and larger files before considering broader +default behavior. The smaller exclusive-bound prefetch opportunity remains +separate. Evidence is in `.tmp/mapped-read-comparison-20261005`. + +## October 5, 2026 Leaf-Page Read Attribution (Baseline Diagnostic) + +With the sparse-decoder candidate restored, a bounded diagnostic investigated +the remaining page-read and buffer costs on production baseline `26a55593`. +It used the unchanged baseline runtime and existing checkpointed fixtures. +Two fresh processes each ran **65 warmups and 65 measured prepared pooled ADO +queries**, returning and validating 4,096 rows plus EOF per query. All 130 +measured queries and reader cleanup checks passed; no workload was retried. +An independent audit verified all 53 pinned inputs and every raw sample. Each +read count matched the fixture's leaf intersections, interior seek depth, and +the source's boundary-prefetch prediction. + +An independent raw-file inspection verified all 20,000 fixture rows and leaf +chains. Numeric records occupy 19 bytes and 136 rows per full leaf; mixed +records occupy 793 bytes and five rows per leaf. Both queries select the same +two integer outputs. The wider underlying records therefore require many more +leaf pages even when the output is narrow. + +| Per 4,096-row query, averaged over 65 parameter values | Numeric fixture | Mixed fixture | +|---|---:|---:| +| Leaves containing returned rows | 31.09 | 820.00 | +| Completed file-read API calls | 32.12 | 822.20 | +| Sequential subset of file-read calls | 30.12 | 819.20 | +| Page-buffer payload bytes, lower bound | 131,576 | 3,367,731 | +| Total allocated bytes | 171,679 | 8,819,733 | + +The built-in file counters were enabled without a storage wrapper or page +interceptor, retaining native sequential read-ahead and featureless execution. +Counter snapshots, reflection, and reporting were outside measurement; query +execution included awaited reader disposal. The counters add a per-read lock +and count completed API calls, including OS-cached reads. They do **not** count +physical disk operations or measure I/O wait. Each successful page read on this +provider allocates a 4,096-byte array; the lower bound excludes array headers, +failed/canceled attempts, decoding, and other allocations. These diagnostic +timings are not performance qualification or a speedup comparison. + +Warmup inspection confirmed distinct snapshot pager/cache objects for successive +readers, each starting empty. Before EOF, the numeric reader retained two cache +pages and the mixed reader three; speculative pages were consumed directly +without cache insertion. Providers and the actual file device were shared. +This matches the intentional pager-local snapshot cache design. Warmups do not +retain these reader pages for later queries. Logical hit/miss counters were +unavailable on this default path and were not inferred from residency. + +The next bounded comparison is the existing **`UseMemoryMappedReads()` option**, +including both 4-row and 4,096-row controls. It can supply page views without +per-page byte arrays, but each snapshot creates and disposes its own mapping, +so small-query overhead must be measured. Sharing a page-ID cache across reader +snapshots would require separate versioning and lifetime design. + +A smaller source opportunity remains: exclusive upper bounds currently retain +a conservative prefetch threshold. At a leaf ending at `upper - 1`, one successor +can be prefetched unnecessarily. This boundary appears in 13 of 65 mixed ranges +and two of 65 numeric ranges; it cannot explain the wide-query page count. +Production code is unchanged. Evidence is in `.tmp/leaf-read-attribution-20261005`. + +## October 5, 2026 Sparse Projection Decoding (Candidate Rejected) + +After `26a55593` committed the buffered-row contracts and rejected experiment, +a decoder diagnostic isolated the cost of materializing skipped values. On +pre-encoded records containing an integer, 256-character text, 512-byte blob, +and another integer, selected decoding reduced allocation from **1,072 to zero +bytes/record**. The eight-process diagnostic reused output arrays and excluded +storage, planning, and ADO delivery; it did not establish a query speedup. + +The production candidate used the configured serializer's existing compact +selected decoder only for ordered, unique, sparse primary-key projections. +It initialized output to NULL for older short records and retained existing +dense, reordered, duplicate, and primary-key-only paths. Six new contract cases +cover row/batch boundaries, missing and NULL fields, retained text/blob outputs, +reopening, and a custom-format serializer. They passed against both original +and candidate implementations. Candidate Release builds were clean; all +**3,384 engine and 319 ADO tests**, including **64 focused cases**, passed. + +A new common harness retained the original nine workloads and added numeric +and mixed sparse projections at 4 and 4,096 rows. The fixed comparison ran +**52 serial processes, 140,000 measured queries, and 43,160 warmups** under normal +desktop conditions. Only the matched Execution assembly differed between +arms. All **972 frozen inputs**, compiler/PDB provenance, fixture copies, +typed values, checksums, and reader-cleanup checks passed. An independent audit +recomputed all raw samples and 26 paired comparisons. No measured run was +retried, excluded, or replaced. + +| 4,096-row mixed sparse comparison | Pair 1 | Pair 2 | +|---|---:|---:| +| Median latency, control to candidate | 11,169.0 to 10,727.7 us | 11,080.8 to 10,744.0 us | +| Median latency change | -3.95% | -3.04% | +| Allocated bytes/query, control to candidate | 8,822,482 to 4,434,982 | 8,821,833 to 4,434,621 | +| Allocation change | -49.73% | -49.73% | + +The candidate **failed qualification**: both wide mixed-query pairs missed +the predeclared requirement of at least 5% lower median latency, although both +met the 20% allocation target. The four-row numeric sparse second pair also +failed the unchanged 5% median regression guard: **41.8 to 44.7 us (+6.94%)**. +All other median/allocation guards and all repeated-P99 guards passed. These +observations do not identify scheduling or a particular instruction as the +cause of the regression. + +`PrimaryKeyRangeScanOperator.cs` was restored to committed `26a55593`; the tests +and complete failed experiment are retained. Final Release verification after +restoration also passed all **3,384 engine and 319 ADO tests**, including the +64 focused cases, with clean builds. This delivers **no production performance +improvement**. The next investigation is leaf-page reads and +buffer allocations for wide records, while preserving read-ahead behavior. +The cursor resolves payloads before projection, and inline payloads are page +memory views; row delivery also reuses its output array. Different fixture +densities make the mixed/numeric timing gap a hypothesis to investigate, +not a causal decomposition. Evidence, including the rejected source and +versioned input resolution, is in `.tmp/projection-decoding-20261005`. + +## October 5, 2026 Buffered Row Delivery (Candidate Rejected) + +A fresh phase diagnostic on committed `f94ce04f` confirmed that pooled ADO +reads use the featureless `QueryResult` path. Consuming the remaining rows and +EOF accounted for **90.08%** of instrumented phase time for a 4,096-row range. +This identifies a broad consumption phase, not a CPU hotspot or proof that +async dispatch dominates decoding, copying, or page reads. + +The candidate returned already-buffered rows synchronously from the private +featureless helper, retaining row copying and ownership. Opening and refill +remained async. Fifteen new contract cases passed against both the original +and candidate implementations, covering boundaries, empty batches, suspended +operations, exception/cancellation behavior, retained rows, and late scopes. +Candidate Release builds had zero warnings/errors; all **3,378 engine and +319 ADO tests** passed, including **45 focused QueryResult cases**. + +The unchanged nine-case public ADO harness ran **36 serial processes, 100,000 +measured queries, and 29,640 warmups**, under normal desktop conditions. Matched +Execution builds differed only in `QueryResult.cs`; compiler inputs, other +runtime DLLs, harness, fixture, and PDB provenance were verified. All 832 frozen +inputs and all row/checksum/reader-cleanup checks passed. No measured run was +retried, excluded, or replaced. + +| 4,096-row primary-key comparison | Pair 1 | Pair 2 | +|---|---:|---:| +| Median latency, control to candidate | 738.7 to 716.4 us | 731.4 to 715.4 us | +| Median latency change | -3.02% | -2.19% | +| Allocation change | +0.24% | +0.28% | + +The candidate **failed qualification**. It missed the predeclared requirement +of at least 3% lower wide-query median in both pairs. It also failed the +unchanged repeated-P99 guard for the four-row join: **167.0 to 213.5 us +(+27.84%)** and **148.3 to 174.5 us (+17.67%)**. All per-pair median and +allocation regression guards passed. The tail result is retained without +attributing it to background activity or claiming a causal mechanism. + +The production change was rejected and `QueryResult.cs` restored to `f94ce04f`. +The contract tests and complete failed comparison remain useful evidence; +this experiment delivers **no production performance improvement**. Before +another candidate, measure the projection/record-decoding cost separately +from batch copying and ADO row delivery. Evidence, including the rejected +source and versioned input resolution, is in `.tmp/row-delivery-20261005`. + +## October 5, 2026 Read-Ahead Cancellation Reuse (Local Allocation Improvement) + +After `30c3a01c` committed the LIMIT fix and allocation-test repair, the next +candidate targeted per-leaf read-ahead setup. A successfully consumed prefetch +now clears its completed task and page ID while retaining the cursor's +uncancelled cancellation source. Subsequent reads can reuse its registration +storage, and successful consumption avoids an unnecessary fault continuation. +Cancellation, read faults, reseeks, range/cache boundaries, and disposal retain +their cleanup paths. Page buffers and retained payload ownership are unchanged. + +The Release builds had zero warnings or errors. All **3,363 engine tests and +319 ADO tests** passed, as did **28 focused cursor cases**. Eight new cases +cover synchronous/delayed completion, cancellation and retry, fault fallback, +reseek with an outstanding read, draining disposal, range-bound cleanup, and +retained payloads. Independent source review found no correctness blocker. + +The fixed nine-case comparison passed all existing regression guards and its +new requirement of at least **5% lower allocation in both wide primary-key +pairs**. It used matched Storage builds, an unchanged common harness and other +runtime DLLs, **650 frozen inputs, 36 serial processes, 100,000 measured queries, +and 29,640 warmups**. All values, checksums, fixture copies, and reader-cleanup +checks passed. No measured run was retried or excluded; normal desktop +conditions applied. + +| 4,096-row primary-key comparison | Pair 1 | Pair 2 | +|---|---:|---:| +| Allocated bytes/query, control to candidate | 179,851 to 170,710 | 179,992 to 170,700 | +| Allocation change | -5.08% | -5.16% | +| Median latency, control to candidate | 734.2 to 735.9 us | 736.1 to 740.1 us | +| Median latency change | +0.23% | +0.54% | + +This establishes a local allocation improvement, **not a latency speedup**. +All median/allocation ratios remained within the 5% per-pair regression guard; +the largest median increase was 4.89% for the 65-row primary-key second pair. +Individual tails varied: the 64-row primary-key second-pair P99 rose 32.77%, +while its first pair fell 13.85%, passing the unchanged rule that flags P99 +only when both pairs exceed 10%. Independent raw-result recomputation verified +all 36 runs and 18 pairs. Release-wide performance qualification remains +separate. Evidence is retained in `.tmp/read-ahead-investigation-20261005`. + +## October 5, 2026 Coercion Allocation Guard Protected from Background GC + +The intermittent coercion allocation assertion was investigated separately +from the LIMIT change. The .NET 10.0.12 background-GC accounting path can count +unused allocation-buffer space as allocated bytes +([runtime issue](https://github.com/dotnet/runtime/issues/134724)). On this +Windows x64 host, a fixed stress probe reproduced two failures of the unchanged +test in 1,024 calls, at 6,192 and 6,816 bytes, plus 44 positive readings from an +allocation-free control. With concurrent GC disabled in a separate diagnostic +process, all 1,024 calls passed and the coercion/empty controls measured zero. +Every deliberate 64-byte allocation still measured correctly. This supports +the failure mechanism; the original untraced 2,520-byte event cannot be +attributed retrospectively with the same precision. + +`SqlTypeCoercionTests` now uses a nonparallel collection and requires a 1 MiB +no-GC region around measurement. Admission and mandatory cleanup must succeed; +assertions run after cleanup. The **4,096-call loop and 1,024-byte limit are +unchanged**. A separate counter interval deliberately formats qualified-target +strings to verify that real allocations remain detectable. Production code +and the full test process's normal GC configuration are unchanged. + +The clean Release build and all **3,355 engine tests** passed, including the +three focused coercion cases. Independent validation invoked the actual built +test and isolated source mutations: eager formatting failed the guard at +**262,144 bytes**, an injected exception propagated after cleanup, and one +forced collection invalidated the no-GC region and failed the test. Every +path restored normal GC mode and admitted a fresh region afterward. No +threshold was relaxed, sample skipped, or validation case retried. + +Diagnosis and repair evidence are retained in +`.tmp/coercion-allocation-20261005` and +`.tmp/coercion-allocation-fix-20261005`. Earlier performance comparisons remain +preserved; this test-only repair does not require another latency benchmark. + +## October 5, 2026 LIMIT Row Path Separation (Local Regression Checks Passed) + +The mixed-traversal correctness fix now separates ordinary row reads from +buffered batch traversal. A non-async dispatcher checks the limit and selects +a small row-source async helper or the existing mixed-traversal logic. This +targets the actual prepared primary-key path: LIMIT at most four without an +offset uses the row `QueryResult` boundary. The batch-forwarding loop remains +unchanged. + +Reflection-only native diagnostics on .NET 10.0.12 x64 confirmed that the +ordinary row async state returned to **56 bytes**, matching `92716dfa`, with +the same fields. Its native state-machine body is 524 bytes versus 531 in +the control; the new public dispatcher is 53 bytes. The larger mixed state +is isolated in a separate helper. These observations establish the intended +code shape, not the cause of previous timing differences. + +The unchanged nine-case comparison **passed all declared regression guards**: +5% per-pair median/allocation limits and 10% P99 only when exceeded in both +pairs. Matched builds reused the same harness and fixture, with **36 processes, +100,000 measured queries, 29,640 warmups, and 2,494 frozen inputs**. Every value, +checksum, fixture copy, and reader-cleanup check passed under normal desktop +conditions. No measured attempt was rerun or excluded. + +The 4-row primary-key medians were **42.9 to 41.7 us (-2.80%)** and +**41.6 to 41.7 us (+0.24%)**. Its allocation increase was about **8 bytes/query** +(0.05%); the preceding unsplit candidate added about 16 bytes/query. Wide +primary-key medians changed -0.01% and +0.15%. Individual P99 results still +varied: the join's first pair rose 34.94%, while its second rose 7.63%, within +the declared repeated-tail rule. This is a local regression screen, not a +general speedup or release-wide performance qualification. + +All **59 focused cases** passed, including four new cases for suspended row +cancellation/faults and empty/short sources followed by reopening. Release +builds had zero warnings or errors. The initial engine suite passed 3,354 of +3,355 tests: the unchanged type-coercion allocation test observed 2,520 bytes +against its 1,024-byte allowance. That class then passed all three cases in +isolation, and one unchanged full-suite confirmation passed **3,355 engine +tests**; **319 ADO tests** also passed. The first failure remains preserved, +its cause is unknown, and no assertion or runtime setting was changed. + +Independent raw-result recomputation confirmed all 36 runs and 18 pairs. Evidence +is retained in `.tmp/limit-row-fastpath-20261005`, together with both earlier +failed performance candidates. This entry records validation before the +follow-up commit; the allocation-test investigation and repair are recorded above. + +## October 5, 2026 Cached LIMIT Source Classification (One Median Check Failed) + +The mixed-traversal follow-up was revised to cache its immutable adapter-source +classification after physical-plan wrapping. Native-code diagnostics confirmed +that repeated predicate helper calls disappeared. The cached bool fits existing +padding: the committed control used 56 bytes for an uninitialized LIMIT object, +and both correctness candidates used 64 bytes. The row async state still grew +from 56 to 64 bytes relative to the committed control; the batch state remained +72 bytes. These are code/layout observations, not timing or allocation-frequency +attribution. All **3,351 engine, 319 ADO, and 55 focused tests** passed again. + +A new, single fixed comparison against `92716dfa` retained the same nine cases, +36 processes, 100,000 measured queries, 29,640 warmups, and regression thresholds, +with 2,049 frozen inputs. All correctness and allocation checks passed. The +comparison **still failed** the 4-row primary-key median guard: **39.2 to 43.1 us +(+9.95%)** in pair 1 and **38.2 to 40.1 us (+4.97%)** in pair 2. The other median +and repeated-P99 guards passed, but individual tails and a wide-range control +run varied substantially. This does not establish why earlier tails changed. +Independent raw-result recomputation confirmed the outcome; no measured rerun +or sample exclusion occurred. + +Further source tracing identified the remaining target: prepared queries with +LIMIT at most four and no offset use the direct row boundary in +`QueryPlanner.PreparedSelect.cs`. The expanded LIMIT row async state therefore +lies on this measured path. Separating ordinary row delegation from mixed +traversal is the next candidate, rather than another batch-dispatch change. +Evidence is preserved in `.tmp/limit-latency-diagnosis-20261005`; this candidate +was not committed or performance-qualified. + +## October 4, 2026 Mixed LIMIT Traversal (Correctness Passed, Latency Check Failed) + +After the batch-forwarding change was committed as `92716dfa`, operator tests +reproduced the deferred cursor-ordering defect. With a 65-row source batch, +LIMIT could return rows 0-63 as a batch and then skip buffered row 64 when +asked for one row. A batch-to-row adapter could instead replay rows because +its cursor and LIMIT's cursor advanced independently. + +The follow-up consumes buffered rows through LIMIT's existing batch position. +Adapter-only sources use that position from the first read; exhausted direct +sources keep their existing row delegation. A lazy owned row buffer honors +the source's reuse flag and is detached on reuse changes and reopening. +The intact-batch forwarding loop is unchanged. The change adds one reference +field and checks on row reads and the small-limit path; it is a correctness +fix, with no claimed performance improvement. + +The original 24 new regression cases failed 22 times on `92716dfa`. After the +fix and an additional adapter boundary case, all **55 focused tests** passed +(25 new and 30 existing). Full Release suites passed **3,351 engine tests and +319 ADO tests**, with zero build warnings or errors. Independent review found +no correctness or ownership blocker in this scope. + +The fixed public ADO comparison reused the preceding harness and fixture, +with matched control/candidate Execution builds. Nine cases ran in +control/candidate/candidate/control order: **36 processes, 100,000 measured +queries, and 29,640 warmups**, guarded by 1,566 frozen inputs. Normal desktop +conditions applied. Every returned value, checksum, fixture copy, and reader +disposal check passed. There was no benefit target and no measured rerun. + +**The latency comparison failed its declared regression checks.** + +| Failed check | Pair 1 | Pair 2 | +|---|---:|---:| +| 4-row primary-key median (5% limit per pair) | 39.3 to 42.1 us (+7.12%) | 40.7 to 41.4 us (+1.72%) | +| 64-row primary-key P99 (10% limit in both pairs) | +16.43% | +16.77% | +| 4,096-row expression P99 (same rule) | +17.65% | +17.59% | +| 4-row join P99 (same rule) | +10.13% | +41.40% | + +All allocation checks and the other median checks passed. The wide primary-key +median changed +2.26% and -0.87%, within its guard. An independent audit +recomputed all 36 raw runs and 18 pairs and confirmed the four flags. +These observations do not establish whether source overhead, code generation, +or host scheduling caused the latency differences. The correctness follow-up +remains uncommitted and performance qualification remains open; investigate +the small-limit path and repeated tail flags before promoting it. Prior +benchmark evidence remains intact. New evidence is retained in +`.tmp/limit-mixed-traversal-20261004`. + +## October 4, 2026 Wide-Range Reads and LIMIT Batch Forwarding (Local Follow-Up) + +After `b1eb30f6` recorded the matched-build investigation, separate profiles +examined the remaining pooled 4,096-row read cost. Instrumented phase timings +placed 89.36% in consumption after the first row, 7.18% in the first read +(which can fill a batch), and 1.67% in `ExecuteReaderAsync`. These diagnostic +timings include allocation-counter and value-validation overhead. Setup used +5,160 bytes/query; the two reading phases accounted for about 174,920 bytes. + +A separate built-in device-counter probe preserved normal speculative reads +and measured 32.12 page reads/query, including 30.12 sequential reads, totaling +131,576 bytes. These are device API calls, including OS-cached reads; they do +not count physical disk accesses. The first counter attempt failed its final +open-file hash check and produced no counter report. That attempt is preserved; +a corrected probe changed only the hash reader's file-sharing mode and source +provenance path, retaining the fixed 650 warmups and 650 measured queries. + +The 10,000-query EventPipe capture reported zero lost events. Allocation stacks +identified owned page buffers and cancellation registration for read-ahead. +Allocation-tick interval weights do not establish exact bytes by type. Most +thread samples were profiler safepoints or waits, so the trace does not establish +a CPU ranking or attribute time to GC pauses. No product change was selected +from sample counts alone. + +Source review found that `LimitOperator` checked whether it could forward an +intact source batch only after its row-copy loop had consumed that batch. The +check was unreachable. It now runs before copying, with separate storage for +owned fallback buffers and borrowed source batches. Forwarding requires a +fully unconsumed batch of at most 64 rows that fits the remaining limit and +satisfies the reuse/ownership rules. Partial tails and larger source batches +still copy. An empty buffer exposed before the first advance keeps its existing +identity; the normal 4,096-row ADO path therefore removes 63 of 64 LIMIT copy +loops. The added reference field slightly increases the LIMIT object size. + +All **3,326 engine tests and 319 ADO tests** passed in Release, with clean +builds. The 119-case focused suite includes 30 new cases for ownership, +retention, reuse toggles, partial tails, large source batches, reopening, +empty inputs, cancellation, asynchronous sources, and fully consumed mixed +row/batch traversal. Independent source review found no actionable issue in +this scope. The pre-existing ordering problem when direct row reads follow +a partially consumed oversized source batch remains a separate follow-up. + +The uninstrumented comparison used matched old/new Execution builds, identical +dependency DLLs and harness, the same verified 10,000-row fixture, and one +case per fresh process. Nine one-table cases with 65 parameter values ran in +fixed control/candidate/candidate/control order: **36 processes, 100,000 +measurements, and 29,640 warmups**. All 1,188 inputs were frozen before the +single run. Normal desktop conditions applied, with no tracing, readiness +gates, or retries. Every returned value, checksum, and reader disposal was +validated. + +**The candidate passed all fixed checks and its improvement target.** The +4,096-row primary-key median improved **7.02%** by geometric mean across the +two pairs, exceeding the declared 5% objective: **807.7 to 749.9 us** and +**800.0 to 745.0 us**. Its P99 fell from 1,315.5 to 1,230.0 us and from +1,301.0 to 1,074.8 us. Allocation changes were +0.07% and -0.18%. + +| Query | Median change, pair 1 | Median change, pair 2 | +|---|---:|---:| +| 4-row primary-key range | -0.23% | -3.50% | +| 64-row primary-key range | +2.40% | +1.62% | +| 65-row primary-key range | +0.64% | +4.45% | +| 256-row primary-key range | -2.56% | -10.43% | +| 4,096-row primary-key range | -7.16% | -6.88% | +| 4,096-row secondary-key query | -5.14% | -4.57% | +| 4-row ordinary expression | -2.30% | -1.68% | +| 4,096-row ordinary expression | +1.30% | -5.49% | +| 4-row join | +3.67% | +2.43% | + +Every pair stayed within the 5% median/allocation regression limits, and no +case exceeded 10% P99 in both pairs. Individual tails still varied: the +65-row case increased P99 by 61.32% in one pair and 6.15% in the other. Passing +the declared repeated-P99 rule does not mean every tail measurement improved. +This is local evidence for the LIMIT change. Earlier lazy-table-scan failures +remain unchanged, and release-wide latency qualification remains open. +Raw profiles, failed diagnostic evidence, matched builds, tests, protocol, +samples, and reviews are retained in `.tmp/prepared-range-profile-20261004`. + +## October 4, 2026 Matched Prepared-Range Rebuild (Local Diagnostic) + +After the lazy table-scan change was committed as `d55c5d98`, a separate +diagnostic investigated the remaining 4,096-row pooled prepared-range flags. +Both earlier comparisons below remain failed. No product code changed during +this investigation. + +Old and new Execution sources were rebuilt with one captured compiler recipe: +the same SDK/compiler, 38 explicit sources in the same order, 172 reference +assemblies, 10 analyzers, revision metadata, SourceLink, source paths, and output +paths. Only `Operators.cs` differed. Independent review verified all 476 build +inputs and PDB document checksums. All 5,040 method bodies matched between each +historical binary and its corresponding rebuild; the two rebuilt variants +differed only in the five expected TableScan methods. This establishes managed +method-body agreement, not byte-identical historical binaries or JIT identity. + +The comparison used three arms: historical control, rebuilt control, and +rebuilt candidate. Each process ran exactly one case against a copy of the same +10,000-row fixture, seeded separately by the historical runtime and verified +without WAL sidecars. This removed differences from earlier cases retaining +connection pools and from each arm creating its own index. Every query still +created its normal reader snapshot; warmup did not preserve a reader pager cache. + +Three one-table, 65-parameter cases ran in fixed historical/control/candidate/ +candidate/control/historical order: prepared ranges returning 4,096 and four +rows, and ordinary expressions returning four rows. There were **18 processes, +96,000 measurements, and 65,910 warmups**, with 923 inputs frozen before the +single run. All rows, checksums, reader disposal, binary identities, and launch +order were validated. The run used normal desktop conditions, without tracing, +readiness gates, or retries. + +**Both the reconstruction and source-change comparisons passed their fixed +checks:** neither pair exceeded the 5% median/allocation limit, and no case +exceeded 10% P99 in both pairs. The source-change results were: + +| Query | Pair 1 median, control to candidate | Pair 2 median, control to candidate | Allocation change, pair 1 / pair 2 | +|---|---:|---:|---:| +| 4,096-row prepared range | 849.7 to 866.9 us (+2.02%) | 839.1 to 843.0 us (+0.46%) | +0.30% / -0.12% | +| 4-row prepared range | 44.9 to 45.3 us (+0.89%) | 46.0 to 45.3 us (-1.52%) | +0.004% / -0.008% | +| 4-row ordinary expression | 67.8 to 68.6 us (+1.18%) | 68.4 to 69.1 us (+1.02%) | -13.33% / -13.34% | + +The 4,096-row source-change P99 ratios increased 11.01% and 7.14%; only the +first exceeded 10%. One reconstruction pair for the four-row range increased +P99 by 19.93%, while the other increased 0.85%. These individual tail movements +remain visible even though they did not meet the declared repeated-P99 rule. + +The earlier large prepared-range slowdown did not reproduce in this setup. +The experiment does not distinguish which setup difference or runtime effect +contributed to the prior failures, establish a latency improvement, or qualify +the full optimization for release. Its expression-only allocation reduction +of about 13.34% is distinct from the earlier 12.89% expression/join aggregate. +Compiler recipes, raw samples, failed initial harness-build log, fixed protocol, +and reviews are retained in `.tmp/prepared-range-latency-20261004`. Further +optimization should first profile the remaining wide-range row consumption; +this result provides no basis for another plan-cache or rebinding change. + +## October 4, 2026 Lazy Table-Scan Batches (Local Allocation Follow-Up) + +After `9af89853`, allocation profiles still showed unused 64-row buffers in +ordinary expression and join queries. `TableScanOperator` allocated a batch in +its constructor and another when opened, even when planning replaced that scan +or execution used only its row API. Batch storage now allocates on first batch +access. Opening a scan and disabling reuse clear the reference; batch size, +decoding, cursor traversal, and retained-batch ownership remain unchanged. + +Separate instrumented probes measured **31,179.39 to 27,020.29 bytes** per +four-row expression query and **66,877.78 to 58,558.34 bytes** per four-row join. +The reductions match one and two discarded two-column batches respectively +(4,160 bytes each on this runtime). Reader-session creation stayed at 344 bytes. +Each probe validated 2,000 queries after 1,040 warmups; instrumented durations +are not latency evidence. + +All **3,296 engine tests and 319 ADO tests** passed in Release, with zero build +warnings/errors. Eight new cases cover row-only laziness, empty inputs, batch +boundaries, retained buffers with reuse disabled, reopen behavior, narrowed and +sparse decoding, zero-column rows, and mixed row/batch cursor advancement. +Independent source review found no actionable correctness issues. + +The original paired comparison used 20 cases per process with 65 parameter +values and 1/256-table catalogs: prepared ranges returning 4/256/4,096 rows, +ordinary expressions returning 4/256/4,096 rows, joins returning 4/256 rows, +and full-table batch scans returning 4/256 rows. The full-scan SQL used +non-sargable `id + 0` bounds and no LIMIT; 16 unmeasured EXPLAIN checks confirmed +`table_scan` plus `filter_projection`. All returned values were checked. + +Four serial processes ran control-forward, candidate-reverse, candidate-forward, +control-reverse: **169,600 measured queries** after **124,280 warmups**. The +baseline Execution DLL is the previously validated implementation committed in +`9af89853` (its build metadata still names `f82ed0b6`). Both arms used identical +current dependency DLLs and harness; only the Execution DLL differed. The +182 frozen inputs include runtime/source hashes, test receipts, and preserved +unrelated files. This ran under ordinary background load without readiness gates. + +**The original comparison failed its latency checks.** Small expression/join +queries allocated **12.89% less** by geometric mean across both catalogs/pairs, +but median latency increased **6.12%**. There were no allocation flags, 13 +paired-median flags above 5%, and three cases whose P99 increased more than 10% +in both pairs. Those repeated-P99 cases were the 256-table four-row expression, +one-table 4,096-row prepared range, and 256-table 256-row full scan. + +Several large slowdowns also occurred in prepared controls that bypass the +changed TableScan path. That observation does not establish their cause or +erase the target-query failures. Allocation savings are established; these +results do not establish a latency improvement or qualify release-wide +performance. Raw timings, failures, diagnostics, and provenance are retained +in `.tmp/lazy-table-scan-batches-20261004`. + +A separate, fixed four-case persistence check covered all three repeated-P99 +cases and the worst small-query median case (the one-table four-row join). +It used the exact original runtime DLLs, an identical new harness in both arms, +and candidate-forward, control-reverse, control-forward, candidate-reverse +order. The unchanged 5% median/allocation and repeated 10% P99 limits applied +to **64,000 measurements** after **44,980 warmups**; four EXPLAIN checks verified +the full-scan path. All 240 inputs, including the original failed findings, +were frozen before the single execution. + +The selected expression, join, and full-scan cases passed. The one-table +4,096-row prepared range failed again: its paired medians increased **30.25%** +(962.4 to 1,253.5 us) and **7.44%** (881.6 to 947.2 us), and P99 exceeded the +10% limit in both pairs. Its allocations increased 0.20-0.87%, within the +allocation limit. Small-query allocation savings remained **12.89%**. + +The original comparison and focused check both remain failed. The prepared +range slowdown's cause is unresolved, and this candidate is not ready for a +performance-qualified commit. Further work should isolate that control before +advancing the buffer optimization. There were no automatic retries or changes +to acceptance thresholds. The focused evidence is under `focused-check`. + +## October 4, 2026 Ordinary Primary-Key Ranges (Local SELECT Follow-Up) + +The arithmetic-projection and join controls from the planner-setup investigation +allocated roughly 410-446 KB to return four rows. Separate instrumented probes +located about 94-96% of those allocations in row consumption. Allocation stacks +showed table-cursor traversal, page reads, and read-ahead. Expression compilation +builds delegate closures; it was not the main cost in these fixtures. + +These ordinary SELECT shapes bypass the prepared-template path and previously +kept a full table scan for primary-key inequalities. An arithmetic projection +can fill a 64-row internal batch before returning rows. When its filter matches +only four rows, it can traverse the entire 10,000-row table despite `LIMIT 4`. +The join's ordering also exhausts its input. The fix adds a bounded physical +integer primary-key source after existing lookup selection fails, in general +SELECT and normal-table join-leaf predicate pushdown. + +The planner reuses exact-integer range extraction and the existing range +operator. Residual predicates run before ordering/TopN, projection, and LIMIT. +Ascending PK order is preserved; other sorts keep the bounded input. Eligibility +uses the actual catalog's physical single-column PK, excludes CTE shadowing and +backed/composite keys, and resolves the tree through the current reader pager. +Mutation selection and prepared execution are unchanged. The tested mixed NULL +residual now correctly returns no rows; the baseline mishandled that residual. +Fractional bounds retain the existing strict INTEGER type error. + +The instrumented candidate used about **31.18 KB per arithmetic query** and +**66.88 KB per join**, versus 410.84 and 444.86 KB in the corresponding baseline +probes. Each probe validated 2,000 measured queries after 1,040 warmups, and all +four traces reported zero lost events. These are diagnostic process-allocation +measurements; sampled allocation intervals are not exact attribution by type, +and instrumented duration is not used for the performance comparison below. + +All **3,288 engine tests and 319 ADO tests** passed in Release, with zero build +warnings/errors. The 27 new cases cover ordinary bounded access, aliases, +intersected/reversed/exclusive bounds and integer extremes, residuals before +sorting/LIMIT/OFFSET, NULL/OR/type behavior, CTE shadowing, nonphysical keys, +joins and null extension, changing pooled parameters, overlapping readers, +schema/root changes, commit/rollback snapshots, and serializable range conflicts. +Independent source review found no correctness issues. + +The uninstrumented comparison used the previous validated lazy-planner working +tree as its baseline (still uncommitted on `f82ed0b6`), with frozen source and +runtime hashes. Identical harnesses ran control-forward, candidate-reverse, +candidate-forward, control-reverse across 1/256-table catalogs. The ten prepared +range controls were joined by arithmetic projections returning 4/256/4,096 rows +and self joins returning 4/256 rows, all with 65 parameter sets. The fixtures +contained 10,000 rows; every value was checked and reader disposal was timed. +There were **272,000 measured queries** after **217,912 warmups**, under normal +background load without tracing or readiness gates. + +Across both catalogs and pairs, four-row arithmetic/join queries had **94.91% +lower median latency** and **89.32% less allocation** by geometric mean. Both +exceeded the declared 50% objectives. Wider ordinary queries also improved in +median latency and allocation in both pairs. Table ranges include both catalogs +and both pairs; KB is decimal. + +| Ordinary query shape | Baseline median | Candidate median | Baseline allocation | Candidate allocation | +|---|---:|---:|---:|---:| +| 4-row arithmetic projection | 1,494.1-1,588.2 us | 70.9-71.9 us | 410.60-410.90 KB | 31.19-31.20 KB | +| 256-row arithmetic projection | 192.7-202.8 us | 140.0-144.1 us | 64.75-64.87 KB | 52.53-52.56 KB | +| 4,096-row arithmetic projection | 1,383.0-1,550.3 us | 1,123.3-1,155.3 us | 208.37-208.80 KB | 197.06-197.60 KB | +| 4-row self join | 1,534.8-1,607.1 us | 85.5-94.9 us | 444.67-445.33 KB | 66.89-66.90 KB | +| 256-row self join | 1,791.1-1,853.7 us | 358.3-376.9 us | 517.61-518.30 KB | 148.72-148.95 KB | + +**The original comparison did not pass its complete regression check.** Two +prepared controls exceeded the 5% median limit in one pair: the one-table, +65-parameter, 4,096-row range increased 7.78%, and the 256-table, 65-parameter, +four-row range increased 8.41%. Their other pairs decreased 0.07% and 5.87%. +No allocation or repeated-P99 regression flag occurred. Those prepared queries +dispatch directly to the unchanged prepared path and do not execute the new +helper; this does not establish the cause of the observed timing differences. + +A separate, predeclared persistence check tested only those two controls in +candidate-forward, control-reverse, control-forward, candidate-reverse order. +It retained the same 5% median/allocation and repeated 10% P99 limits, used 1,000 +measurements for the larger range and 10,000 for the short range per process, +and ran once: **44,000 measured queries** after **35,620 warmups**. Both controls +passed in both pairs; median changes ranged from a 0.84% decrease to a 0.82% +increase, with effectively unchanged allocations. The original failed result +is retained separately. A persistent control slowdown was not reproduced, but +the follow-up does not turn the original full comparison into a pass or qualify +release-wide performance. + +Evidence is in `.tmp/ordinary-query-setup-20261004`, with the focused comparison +under `controls-followup`. An independent audit verified all 171 original +inputs, raw checksums/percentiles, paired ratios, query counts, and the two +original flags. A separate independent audit verified all 225 focused-check +inputs and confirmed its counts, metrics, unchanged runtime binaries, and zero +flags. Allocation totals cover measured process intervals. Harness GC +counters include post-loop serialization and are not used as measured-loop +counts. These results do not establish broad query or durable-write tail-latency +gains; individual P99 values still vary. + +## October 4, 2026 Planner Setup (Local Allocation Follow-Up) + +After `f82ed0b6`, a synchronous component probe measured 2,808 allocated bytes +for reader planner/catalog-view creation. Each planner eagerly constructed 18 +dictionaries, a hash set, two queues, and adaptive diagnostics with five bound +delegates. Simple prepared ranges leave most of those objects unused. + +Planner caches and adaptive runtime diagnostics now allocate on first use. +Cleanup, invalidation, empty diagnostics, and unused row-ID lease checks access +nullable backing fields without creating containers. Existing comparers, cache +limits, eviction order, allocated capacity, and planner ownership are preserved. +The observer remains a stable planner-lifetime adapter; setting it does not +allocate adaptive diagnostics. Each pooled query still uses its own planner +bound to its reader snapshot. + +The identical component probe measured **848 bytes per planner/catalog view**, +down **1,960 bytes (69.80%)**. Reader-session and snapshot-pager component costs +remained 344 and 1,928 bytes. These synchronous allocation measurements are +component costs, not complete-query performance. + +New regression coverage also exposed an existing trigger-invalidation bug on +the committed baseline: after dropping and recreating a trigger, fast inserts +could execute its old cached body. Direct simple/parsed insert entry points +bypassed the normal schema-version check. Both now invalidate schema-sensitive +caches before accessing them. The unchanged-version path returns immediately. +The regression preserves the expected audit values and covers both insert +routes, trigger removal, and generated row IDs. Other new cases cover adaptive +observer initialization/propagation and expression/prepared metadata refresh +through schema replacement and rollback. + +All **3,265 engine tests and 315 ADO tests** passed in Release, including five +new regression cases. Both final builds had zero warnings or errors. The initial +failure, baseline reproduction, and final passing receipts are retained. An +independent source review found no further correctness issues. + +The matched pooled ADO comparison used four fresh serial processes in +control-forward, candidate-reverse, candidate-forward, control-reverse order. +The same harness and runtime settings covered 1/256-table catalogs, four-row +primary-key ranges with 1/65/2,048 parameter sets, and 256/4,096-row ranges with +65 parameter sets. Four-row arithmetic projections and self joins with 65 +parameter sets additionally exercised ordinary planner caches. Fixtures held +10,000 rows; every query checked both returned values and included reader +disposal in its timing. There were **267,200 measured queries** after **215,832 +warmups**. Normal background load was allowed; no tracing, readiness gate, or +workload retry was used. + +Four-row prepared ranges allocated **10.01% less** by geometric mean across both +catalog sizes and pairs, exceeding the declared 8% target. Their observed +geometric-mean median latency was **5.36% lower**. Ranges below include every +catalog size and pair; KB is decimal. + +| Query shape | Baseline median | Candidate median | Baseline allocation | Candidate allocation | +|---|---:|---:|---:|---:| +| 4-row prepared range | 43.1-46.7 us | 41.3-43.7 us | 18.27-18.45 KB | 16.43-16.61 KB | +| 256-row prepared range | 97.6-139.0 us | 96.1-101.3 us | 36.60-36.85 KB | 34.75-34.80 KB | +| 4,096-row prepared range | 783.1-796.5 us | 773.3-825.2 us | 181.17-181.30 KB | 179.19-181.09 KB | +| 4-row arithmetic projection | 1,355.6-1,364.7 us | 1,369.3-1,391.2 us | 410.69-411.11 KB | 409.20-409.76 KB | +| 4-row self join | 1,373.7-1,402.3 us | 1,389.0-1,405.0 us | 445.30-445.91 KB | 443.49-444.03 KB | + +Every case stayed within the declared 5% median/allocation regression limit. +No case had a P99 increase above 10% in both pairs. Two single-pair P99 increases +exceeded that threshold: the one-table arithmetic projection increased 21.89% +(3.15% in its other pair), and the 256-table/2,048-parameter short range increased +21.39% (decreased 5.57% in its other pair). The 256-row baseline also had one +139.0 us median/673.0 us P99 outlier; it remains in the results. Expression +medians increased in both pairs while remaining within the declared limit. +These controls establish no broad latency gain for larger or more complex +queries. They do not measure adaptive execution or durable-write performance. + +Evidence is in `.tmp/pooled-planner-setup-20261004`: frozen source/runtime inputs, +component measurements, test receipts, raw durations, and comparison findings. +An independent check verified all 156 pinned inputs and recomputed query counts, +checksums, per-case metrics, paired ratios, and thresholds from the raw results. +Allocation totals cover the measured process interval. Harness GC counters +include post-loop serialization and are not used as measured-loop GC counts. +These local results do not qualify release-wide performance or close the +separate durable-write tail-latency diagnostics. + +## October 4, 2026 Snapshot Pager Setup (Local Allocation Follow-Up) + +After `b0de2b6f`, a synchronous component probe measured 3,472 allocated bytes +for snapshot pager creation/disposal, 2,808 bytes for reader planner/catalog-view +creation, and 344 bytes for reader-session creation/disposal. An unused writer +resource-diagnostics dictionary alone allocated 1,016 bytes with the measured +.NET processor count of 16. Each snapshot also constructed a write allocator +and seven delegates whose operations immediately reject read-only writes. + +Snapshot pagers now omit the writer resource dictionary and use a shared, +stateless read-only allocator. Allocation/free still return faulted ValueTasks +with the same errors, including when cancellation is requested or page zero is +passed. Writer pagers retain their normal allocator and diagnostics dictionary. +The same component probe measured **1,928 bytes per snapshot pager**, a reduction +of **1,544 bytes (44.47%)**. Reader sessions and planner/catalog-view creation +retained their measured component costs. + +Three new regression cases cover snapshot diagnostics/reset isolation, exact +write-rejection behavior, subsequent writer page allocation/free/reuse, and +retained readers fetching old contents after new commits. Full Release suites +passed **3,260 engine tests and 315 ADO tests**; both product builds had zero +warnings or errors. An independent source review found no correctness issues. + +The unchanged ten-case pooled ADO harness compared the committed baseline with +the candidate in control-forward, candidate-reverse, candidate-forward, +control-reverse order. It validated every returned value and timed complete +queries including reader disposal. Cases covered 1/256-table catalogs, four-row +ranges with 1/65/2,048 parameter sets, and 256/4,096-row ranges with 65 parameter +sets. The file-backed fixtures contained 10,000 rows and selected two columns. +There were **249,600 measured queries** after 206,472 warmups. Normal background +activity was allowed; tracing was disabled and no workload was retried. + +Four-row queries allocated **7.75% less** by geometric mean across catalog sizes +and both pairs, exceeding the declared 7% target. The observed geometric-mean +median latency was 2.05% lower, a modest local timing change. Individual timings +varied in both directions. Ranges below span both catalog sizes and pairs; +allocation uses decimal KB. + +| Rows returned | Baseline median | Candidate median | Baseline allocation | Candidate allocation | +|---|---:|---:|---:|---:| +| 4 | 46.7-49.5 us | 45.7-48.2 us | 19.80-19.97 KB | 18.26-18.43 KB | +| 256 | 100.8-103.6 us | 99.3-107.1 us | 38.15-38.19 KB | 36.56-36.65 KB | +| 4,096 | 790.0-808.2 us | 798.3-818.6 us | 183.14-183.82 KB | 181.86-182.48 KB | + +All cases stayed within the declared 5% median/allocation regression limit. +No case had a P99 increase above 10% in both pairs. The 256-table, 65-parameter, +four-row case had one P99 increase of 16.16% and a decrease of 9.93% in its other +pair; the repeated-regression check passes without implying every tail improved. +The larger-range checks +support the allocation change without establishing a latency improvement for +those ranges. Harness GC collection counters include post-loop serialization +and are not used as measured-loop GC counts. + +Evidence is in `.tmp/pooled-reader-setup-20261004`: frozen source/runtime inputs, +component measurements, test receipts, raw query durations, and comparison +findings. An independent check verified all 92 pinned inputs and recomputed +counts, row checksums, per-case metrics, ratios, and thresholds from the raw data. +These local results do not qualify release-wide performance or +resolve the separate durable-write tail-latency diagnostics. + +## October 4, 2026 Bounded Cursor Read-Ahead (Local Allocation Follow-Up) + +Allocation profiling of the pooled prepared range path at `fcdfc7ea` found a +speculative next-leaf read even when the requested upper bound was already on +the current leaf. That read allocates a 4 KB page plus asynchronous I/O and +cancellation state. The separate instrumented baseline measured about 25.35 KB +per four-row query: 8.54 KB while opening the reader, 16.15 KB while reading rows, +and 0.61 KB while disposing it. Creating a reader session alone used 344 bytes. +Allocation stacks identify the read-ahead call path; sampled allocation ticks +are not exact attribution of interval bytes to individual object types. + +`BTree.CreateCursor(range)` now passes the upper bound as a read-ahead hint. +Once the current leaf's last key reaches that bound, the cursor skips speculative +reads of later leaves. Traversal and reseeking remain unrestricted, and callers +still enforce query bounds. Longer scans retain read-ahead through needed leaves. +Snapshot ownership, isolation, durability, and plan reuse are unchanged. + +Eight focused tests cover inclusive/exclusive bounds, initial seek/iteration, +cross-leaf prefetch, unbounded scans, and traversal/reseek beyond a hint. Six +reproduced the unnecessary read before the fix; all eight passed afterward. +Full Release suites passed **3,257 engine tests and 315 ADO tests**, with zero +warnings or errors in the final product builds. + +The uninstrumented comparison used identical harnesses in four fresh serial +processes: control-forward, candidate-reverse, candidate-forward, control-reverse. +Each process tested 1/256-table catalogs, four-row ranges with 1/65/2,048 recurring +parameter sets, and 256/4,096-row ranges with 65 parameter sets. Fixtures contained +10,000 rows. Every query used the public pooled ADO path, checked every returned +value, and included reader disposal in its timing. There were **249,600 measured +queries** after 206,472 warmups; normal background load was allowed and no workload +was retried. Tracing was disabled during the comparison. + +Four-row queries improved geometric-mean median latency by **23.15%** and +allocation by **21.73%** across both catalog sizes and balanced pairs. The larger +queries passed the same regression checks. Ranges below span both pairs and +catalog sizes; KB is decimal. + +| Rows returned | Baseline median | Candidate median | Baseline allocation | Candidate allocation | +|---|---:|---:|---:|---:| +| 4 | 58.7-61.5 us | 44.4-47.7 us | 25.44-25.45 KB | 19.80-19.97 KB | +| 256 | 109.1-111.3 us | 98.3-101.8 us | 43.23-43.27 KB | 38.13-38.18 KB | +| 4,096 | 787.4-789.2 us | 776.5-788.2 us | 187.54-187.99 KB | 182.55-182.93 KB | + +No case exceeded the declared 5% median/allocation regression threshold. No case +had a P99 increase above 10% in both pairs; individual P99 increases remain in +the raw results. The declared four-row allocation improvement target was 10%. +A separate candidate allocation trace measured 19.96 KB per four-row query, +consistent with the uninstrumented comparison; its timings are not used above. + +Evidence, raw query durations, source/runtime hashes, test receipts, and traces +are in `.tmp/pooled-query-allocation-20261004`. These are local results under normal +background load, not release-wide qualification or a resolution of the earlier +durable-write tail-latency diagnostics. + +## October 4, 2026 Isolated Pooled Prepared Primary-Key Ranges (Local Differential) + +Commit `0dc0a36f` retains the reader catalog and checkpoint correctness fixes with +reader prepared primary-key ranges disabled. A separate candidate enables the +existing bounded range planner for `ReaderSession`, including pooled ADO reads. +That switch is the only functional product change; both arms share the same +snapshot/catalog/checkpoint implementation. Existing prepared equality and +secondary-index paths are unchanged. + +The candidate passed 3,249 engine tests and 315 ADO tests in Release, with no build +warnings or errors in the final builds. Tests assert range operators over old +snapshot roots after growth, one cached reader plan across 65/2,048 parameter +sets, and bounded pooled reads while another connection commits or rolls back +root-changing writes. The initial ADO test build exposed inaccessible internal +inspection members; the test uses reflection for that assertion in the final +passing build. + +Four fresh serial processes used one fixed harness against the committed guarded +baseline and candidate: control-forward, candidate-reverse, candidate-forward, +control-reverse. Six cases covered 1/256-table catalogs and 1/65/2,048 recurring +parameter sets. Each file-backed fixture contained 10,000 rows; the public pooled +ADO query selected four rows by primary-key range with `ORDER BY id LIMIT 4`. +Each case used at least 8,192 warmups rounded to a complete parameter cycle and +10,000 measured queries: 197,112 warmups and 240,000 measurements in total. +Background activity was allowed as requested. Priority, affinity, durability and +the 240-second process timeout were unchanged; no workload was retried. + +All six cases passed the declared checks: no median/allocation increase above +5% in either pair, no P99 increase above 10% repeated in both pairs, and at least +10% geometric-mean median improvement for cardinalities above 64. The observed +geometric-mean median improvement for the 65/2,048-set cases was **73.38%**. +Ranges below span both catalog sizes and both balanced pairs; allocations are +decimal KB per query. + +| Recurring parameter sets | Baseline median | Candidate median | Baseline allocation | Candidate allocation | +|---|---:|---:|---:|---:| +| 1 | 78.6-82.9 us | 63.5-65.9 us | 39.44-39.50 KB | 25.44 KB | +| 65 | 101.5-103.4 us | 63.6-66.1 us | 46.21-46.22 KB | 25.44-25.45 KB | +| 2,048 | 567.6-595.1 us | 63.3-71.9 us | 192.10-192.22 KB | 25.43-25.44 KB | + +Every query validated its exact rows and disposed its reader. Before/after +dispatch samples confirmed bounded primary-key scans for the candidate and the +baseline table-scan path, with no leaked reader sessions. Pooled executions +create fresh planners, so this is a bounded-access improvement, not shared plan +reuse across pooled queries. Persistent reader sessions separately passed the +one-plan/changing-values regression tests. + +Evidence is in `.tmp/pooled-prepared-range-20261004`. The first analyzer retained +a 32-case loop bound from its parent harness; it failed after all four workloads +had completed. The separate `analysis-r2` correction iterates the declared six +cases without changing samples or thresholds. An independent Python check +recomputed percentiles and ratios from all raw samples and verified all 196 +original evidence files. The original analysis failure remains preserved. + +This local comparison supports the isolated reader-range enablement. It does +not qualify release-wide performance or clear the earlier durable-write P99 +diagnostics, whose changes and measurements were broader in scope. + +## October 4, 2026 Reader Snapshot Catalog (Local Diagnostic, Not Qualified) + +The candidate replaces live reader catalog references with committed snapshot metadata, +including old table/index roots after splits, schema changes, and overlapping commits. +Checkpoint tests also exposed unsafe copying over pages still read from the database +file by an old snapshot; copying now respects the pages visible to every reader. + +An initial 32-case differential screen against `ebf0257` found full-catalog traversal +after commits, warm-lookup overhead, and an extra commit-completion allocation. +The next candidate uses a bounded catalog-change journal, shared immutable metadata, +a per-reader table cache, and completion tracking within existing pager tasks. + +The second screen used four balanced serial processes, 7,076,864 measured operations, +and 758,776 warmups. All median and allocation guards passed. Post-commit reader +creation was 3.1–3.8 us across 1, 32, and 256 tables, allocating about 728 bytes; +the control ranged from 5.2–32.1 us and 848–22,944 bytes. Experimental pooled +prepared primary-key ranges with 65/2048 recurring parameter values had 75.52% +lower geometric-mean median latency. Each pooled read creates a fresh planner, +so this is an access-path result, not cross-query plan-cache reuse. + +The second screen still failed the repeated 10% P99 guard for the one-table +commit-plus-lookup cycle. A separately frozen follow-up retained the same product +binaries, timed cycle, fixtures, and gates, and covered all nine commit cases in +four balanced processes with 4,096 measured and 512 warmup cycles per case +(147,456 measured commits). It again passed median/allocation guards but failed +P99 guards: 32-table commit-plus-lookup total was 38.86%/15.45% slower, driven +by the write/commit phase, and one-table post-commit COUNT reader time was +46.15%/14.53% slower. Temporal variability was observed; its cause is unproven. +No samples, windows, or failed runs were removed to obtain a passing result. + +Reader/pooled prepared primary-key range enablement therefore remains disabled. +The correctness changes are retained, but the candidate is not performance-qualified +or promoted. Local evidence is retained under `.tmp/reader-snapshot-catalog-20261004`, +including `iteration-2` and `iteration-2/commit-tail-investigation`; source/binary +hashes, correctness receipts, raw phase samples, GC observations, and failed summaries +are preserved. The guarded final source is recorded separately from the measured +candidate. These results do not update release benchmark tables or establish a +portable latency guarantee. + +The final guarded source passed all 3,247 engine tests and 315 ADO tests, with +zero build warnings/errors. Final hashes and test receipts are in `final-guarded`. + +A fixed phase-attribution follow-up used the same archived product binaries in +four balanced processes for one-table COUNT and 32-table lookup after commits: +32,768 measured cycles and 4,096 warmups. All result, durable-reopen, raw-sample, +binary-identity, and zero-event-loss checks passed. The analysis is retained in +`phase-attribution/analysis-r2`. Its first analyzer incorrectly expected the declared +EventSource GUID instead of TraceEvent's name-derived provider GUID; the narrow +versioned correction, pinned-parser proof, and original failed analysis are retained. +No workloads were repeated to repair that analysis error. + +Every case/arm had eight checkpoint frame drops among its 41 slowest commits, +at identical sample positions. In the other 33 slow commits, recorded durable-flush +wall time accounted for 89.0–96.9% of commit time. Six non-checkpoint samples had +more than 1 ms outside that recorded flush interval; all coincided with GC counter +increases. Aligned traces confirmed a complete nonconcurrent GC interval inside +each of those six commits (0.926–4.497 ms). Queued write/drain, buffered flush, +and group-window counters stayed zero. Commit stack samples included the durable +flush policy calling Windows `FlushFileBuffers` in every arm. +Every COUNT used metadata without creating a reader Pager for a scan. + +These instrumented captures did not repeat the earlier P99 flags, but cannot +qualify performance or establish that the original failures were harmless noise. +Observed sampling cadence was about 2 ms, and trace alignment uncertainty was +10.4–15.8 us; neither resolves an individual microsecond COUNT tail. Flush wall +time includes scheduling and runtime pauses, so native disk and scheduling evidence +is still needed to separate device latency from time off CPU. The earlier failed +screens remain authoritative, and reader/pooled prepared ranges remain disabled. + +A subsequent administrator-run native capture completed all four prescribed arms +without retries: 32,768 measured cycles and 4,096 warmups. Frozen +binary/input hashes, result checksums, durable reopen checks, and every scalar +sample passed validation. TraceEvent and independent `xperf` statistics reported +zero lost events/buffers. All 32,768 commits matched WAL file-flush requests by +outstanding IRP, exact fixture path, and successful completion status. The 64 +checkpoint commits had multiple WAL flushes and are retained separately; each of +the other 32,704 commits had one matched WAL flush. No file/disk IRP lifetime +matched across layers, so disk timing proximity was not used to infer causality. + +The native capture did not meet its full acceptance contract. Control-forward +is missing the COUNT measured-start marker, and candidate-reverse is missing +the lookup measured-start marker, including after manifest priming and raw-event +inspection. The cause of these missing events is unproven. Candidate-forward and +control-reverse retain all four markers within the exact scalar QPC brackets. +Kernel process/thread lifetimes and all 16 processor envelopes cover measurement. +Conservative scheduler reconstruction retains ambiguous event ordering as unknown +in 872 ordinary commits across all four arms; no samples are removed. Independent +context-switch pairing validates the interval arithmetic and every reported wait +duration, without promoting the unknown intervals or repairing absent markers. + +In the two fully marked runs, each case's 41 slowest commits again includes eight +checkpoint commits. Across the remaining 33, native WAL file-flush time accounts +for 99.52–99.56% of the instrumented durable-flush interval. Its issuing thread +spends 87.0–92.4% waiting, 1.8–4.8% runnable but unscheduled, and 4.9–9.8% scheduled; +up to 0.056% remains unknown. These are time-weighted cohort shares, not per-sample +guarantees. Scheduling delay reaches 2.211 ms in one such commit, although waiting +dominates the cohorts. Waiting during a file flush does not isolate physical-drive +latency from the filesystem, filters, drivers, or other synchronization. + +Offline analysis is retained in `native-attribution/analysis-r2`, with independent +audit and source/output hashes. The [pinned TraceEvent parser](https://github.com/microsoft/perfview/blob/v3.1.21/src/TraceEvent/Parsers/KernelTraceEventParser.cs#L4776-L4812) misdecodes the +version-3 disk-flush completion thread ID; those header identities are excluded, +and the join uses the initialization thread and outstanding IRP. The first failed +analyzer's unsupported 10 us equality check between separately logged disk-event +timestamps and the response-time payload is preserved, with both durations retained +by the corrected analysis. The earlier marker-only acceptance field is superseded +by the explicit failed full-coverage result in `analysis-r2`. + +These observations narrow the remaining write tail to time inside durable flushes, +with scheduling contributing to some outliers. They do not establish a new C# bug, +qualify the candidate, or justify changing durability guarantees. No product source +was changed or rebuilt for this analysis. Further storage-stack attribution needs +identity-preserving evidence; any candidate change still requires the original +uninstrumented gates. Reader/pooled prepared ranges remain disabled. + +A source/trace follow-up confirmed that the serial workload has no concurrent +commit to group and no redundant ordinary-commit WAL flush to remove. All target +ReadyThread records in the two fully marked arms have unavailable emitter PID/TID +(72,868 and 73,220 records); the first capture has no blocking/wake-up stacks. +The missing causal evidence cannot be recovered by assigning nearby disk events. + +A second administrator-run capture, `native-stack-attribution`, completed the +four frozen arms once each: 32,768 measured cycles and 4,096 warmups. It adds +context-switch, wake-up, file-flush and disk-flush-init stacks plus driver events, +with separate marker buffers. All 53 frozen inputs, runtime identities, scalar +results and durable reopen checks passed. Every arm retained all four markers +inside the exact scalar QPC brackets, complete target lifetimes and all 16 CPU +envelopes. TraceEvent and independent `xperf` statistics reported zero lost +events/buffers. No workload or capture was repeated to repair analysis. + +All measured commits matched successful WAL file-flush operations; 32,704 ordinary +commits had one, and the 64 checkpoint commits remain separate. An independent +context-switch audit validates every wait duration. Ambiguous scheduling intervals +remain unknown in 782 ordinary commits. Across the 33 ordinary commits in each +case/arm's fixed top-41 cohort, native WAL flush time accounts for 98.86–99.33% of +the recorded durable-flush interval. The issuing thread spends 82.73–94.51% of +native flush time waiting and 0.68–5.97% runnable but unscheduled; up to 0.096% +remains unknown. These are time-weighted cohort shares. + +Exact original QPC/CPU keys and a separate scan of all stack-trigger events +validate the originating events and thread roles. The context-switch stack belongs +to the incoming thread, so attribution uses the issuer's resumption stack as wait-site +evidence, paired with the exact ReadyThread stack. Of 98,959 reconstructed waits, +98,958 have unique paired stacks wholly inside the WAL flush. One 3.449 ms wait +crosses the file-operation completion boundary and remains unattributed under that +containment rule. No nearest-timestamp joins or process-name guesses are used. +Captured module identities and PDB GUID/age checks resolve 53 of 61 requested +modules; unresolved addresses remain explicit. + +Across all ordinary commits in the eight case/arm combinations, 96.45–97.51% of +waiting occurs in `ClassSendSrbSynchronous`, below `DiskFlushDispatch` and +`NtfsCommonFlushBuffers`. The paired wake-up stacks contain +`ClasspSendSynchronousCompletion` and Storport's `RaidUnitCompleteRequest`. +In the fixed slow-commit cohorts this path accounts for 58.44–93.41% of waiting. +Another disk-flush wait, directly under `DiskShutdownFlush` and readied through +`DiskFlushComplete`/`RaUnitFlushSrb`, contributes 0–23.44% of those tail cohorts. + +The traces also identify Windows kernel heap-lock waits during `NtfsReportDirNotify`, +accounting for 4.26–17.05% of tail-cohort waiting. Their wake-up stacks release the +heap lock, usually from kernel heap compaction. Both control and candidate show +these native paths. This locates wait sites and completion paths; it does not +separate physical-media time from controller/driver work, identify a faulty filter, +or prove the cause of the earlier uninstrumented regressions. No driver record +shares an outstanding WAL file-flush IRP, and file/disk IRPs still differ; child +requests are not connected using timing proximity. + +The final receipt is `native-stack-attribution/final-findings.json`, with raw ETLs, +full typed exports, exact stack joins, symbol identities and independent audits +retained alongside it. The first symbol lookup timed out on a missing driver PDB +and skipped subsequent server lookups; bounded exact-identity downloads repaired +that analysis without new captures. Independent audit r2 accounts for an explicit +ReadyThread after an unknown switch-out state, retaining uncertainty before ready; +the original failed audit is preserved and no tolerance was relaxed. + +This completes the bounded native-stack investigation without establishing another +CSharpDB source fix. Ordinary serial commits already issue one durable flush with +no concurrent commit to group. The [Windows flush contract](https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-flushfilebuffers) +remains unchanged. Product sources and binaries were not changed or rebuilt. +Instrumentation affects timing, so these results cannot qualify performance or +clear the earlier failed gates. Reader/pooled prepared ranges remain disabled. +The next separate investigation is the microsecond COUNT reader tail; original +uninstrumented qualification remains required before enabling the experimental path. + +### COUNT Reader Follow-up: Rejected Exact-Count Lookup Candidate + +An offline review of 49,152 COUNT samples from the three diagnostic captures +confirmed metadata counts without a reader Pager scan. Reader creation contributes +substantial wall time; COUNT execution also takes a larger share in the snapshot +candidate than in the original control. These instrumented phase observations do +not identify the cause of the original failed 23.4-to-34.2/26.8 us reader P99 checks. + +A narrow candidate stored snapshot exact-count eligibility and value in one +immutable map, removing the second lookup on a fresh COUNT. The unchanged fixed +nine-case commit-tail harness compared it against the final guarded pre-change +code, rather than HEAD. Four serial balanced runs covered 147,456 measured commits +and 18,432 warmups. The 18 runtime DLLs differed only in `CSharpDB.Storage.dll`; +timing boundaries, fixtures, options, environment, and original thresholds were +unchanged. Before timing, the candidate passed 3,249 engine and 315 ADO tests with +zero build warnings/errors, including two additional count snapshot/trust tests. + +All raw evidence, checksums, and reader-lifecycle checks passed, but the performance +screen failed nine median checks. There were no allocation flags or repeated P99 +flags. One-table COUNT reader medians changed from 10.6 to 15.4 us in the first +pair and from 23.0 to 11.9 us in the second; its P99 changed from 63.5 to 62.2 us +and from 82.0 to 62.9 us. Allocations remained 903.44 bytes per reader cycle. +COUNT at 32/256 tables and other reader cases also varied across the pairs. +The unchanged control's own order-dependent timings demonstrate substantial +variation; this does not establish which environmental or execution effect caused it. + +The optimization was rejected. Its source, tests, binaries, successful correctness +receipts, failed performance screen, and all samples are retained in +`count-reader-tail`. Both edited files were restored byte-for-byte to the guarded +pre-change versions, and rebuilt product DLLs were checked against the pre-change +hashes. No selective rerun, threshold change, commit, or experimental range enablement +followed. The COUNT tail remains unresolved. + +The subsequent identical-binary and thread-cycle diagnostic is recorded below. +It preserves the original qualification gates and handles thread identity, GC, +and observation overhead explicitly. + +### COUNT Repeatability and Thread-cycle Diagnostic + +A frozen two-stage diagnostic in `count-repeatability` completed once, without +retries. Every process used the same 18 guarded product/dependency DLLs. The first +stage reused the unchanged nine-case harness, with 512 warmups and 4,096 measured +cycles per case in four balanced serial processes: 147,456 measured commits and +18,432 warmups. Control/candidate are labels only; there is no product difference. +All raw-data, checksum, lifecycle, environment and binary-identity checks passed. + +The original thresholds flagged 29 median comparisons and three repeated P99 +comparisons, with no allocation flags. The repeated P99 flags were the nested +commit, write and total phases of one-table commit-plus-reader-creation, rather +than three independent cases. Identical-binary one-table COUNT reader medians +ranged from 19.6 to 38.9 us, with P99 from 91.2 to 150.3 us. All COUNT reader +allocations were 903.4375 bytes per cycle. This establishes substantial repeatability +variation on this host. It does not identify its cause, reproduce the exact earlier +COUNT P99 guard failure, or clear any failed product screen. + +The second stage covered COUNT at 1 and 32 tables in four fresh serial processes: +32,768 measured cycles, 4,096 warmups, and 32,768 empty calibration pairs around +each process's cases. Native stamps around reader creation and COUNT execution retained +core QPC boundaries, native thread IDs, observed processor IDs, thread cycles, GC +counters and synchronous-completion status. All raw hashes, independent checksums, +ordered boundaries, WAL/GC counters and durable reopen checks passed. Every COUNT +used metadata without a scan Pager, every measured query completed synchronously, +and both phases had valid same-thread cycle observations for all 32,768 rows. +The 64 checkpoint cycles, 336 creation/299 execution observations with changed +processor IDs, and all wall-time samples remain included. + +Across each case/process's fixed 41 slowest reader samples, median observed cycles +were 1.95-3.74 times the corresponding full-case median for both creation and +execution. Three creation observation envelopes overlapped a GC counter increase; +none of the execution envelopes did. Four fixed 1,024-sample windows also show +within-process drift: in control-forward one-table COUNT, reader medians were +27.0, 73.7, 72.4 and 85.9 us, while creation median cycles were 33,288, 92,506, +91,104 and 108,624. These are descriptive measurements, not a new qualification. + +Observation cost remains significant. Empty-pair calibration medians were +1,402-1,752 cycles (1.63-3.88% of phase-observation medians), below the frozen +25% calibration flag. However, the actual two cycle-query brackets around creation +had median total wall times of 3.0-5.4 us, alongside creation core medians of +11.1-22.9 us. Execution query brackets were 0.8-1.3 us. Warm empty-loop calibration +does not establish negligible instrumentation cost after a durable commit. No +overhead is subtracted, and instrumented timing is not compared with the plain +harness as a performance gain or regression. + +[Windows thread cycles](https://learn.microsoft.com/en-us/windows/win32/api/realtimeapiset/nf-realtimeapiset-querythreadcycletime) +remain cycle counts; they are not converted to elapsed CPU time or instruction +counts. Cycle observations include probe work and may reflect cache, scheduling +and processor effects. Equal observed thread/processor IDs do not rule out +intermediate scheduling or migration. The diagnostic shows that slow reader +cohorts also accumulate more observed cycles, but does not isolate a CSharpDB +instruction path or establish the cause of the original COUNT tail regression. + +The final receipt is `count-repeatability/final-findings.json`; raw samples, +calibration, frozen inputs and offline analyses are retained with hashes. All +23 guarded source/test files and five unrelated files are unchanged. No product +code was changed or rebuilt, no new correctness suite was claimed, and nothing +was committed or pushed. Reader/pooled prepared ranges remain disabled and the +candidate remains unqualified. The next step is to establish a repeatable benchmark +environment before another optimization or qualification attempt, then profile +reader creation/execution with lower disturbance. No further workload is started +by this diagnostic. + +### Host Readiness Follow-up: External Activity Blocks Measurement + +A subsequent 45-second host-only observation reused the repository's unchanged +`Watch-LocalPerformanceEnvironment.ps1` policy. It did not start a benchmark, +modify product code, change Windows settings, or stop applications. Existing +read-only installer, restart, pending-file-operation and Application event-log +checks were imported from `Test-LocalDurablePerformance.ps1` without running its +benchmark/publication wrapper. + +All 38 monitor samples exceeded the external CPU and process-I/O thresholds. +Observable external CPU ranged from 2.92 to 4.19 logical-core equivalents, +approximately 18.3-26.2% of this 16-thread machine. The policy limits are +0.5 core or 8% CPU, and 4 MiB/s aggregate process transfer activity. Its five +consecutive-busy-sample rule marked 34 samples contaminated, beginning with the +fifth sample. No prohibited build/update process was observed. +The monitor's maximum sample gap was 1.340 seconds, below its 5-second limit. + +Stable process identities observed at both endpoints attributed average CPU +usage of 1.74 cores to `cod23-cod` (its window title identifies Call of Duty: +Modern Warfare III), 0.90 to Windows `TextInputHost`, and 0.49 to the two busiest +Claude processes. The monitor itself used about 0.21 core and +was excluded from external load by its existing process-tree policy. There were +169 enumerated external processes with inaccessible CPU/I/O counters per sample; +they remain unknown, while observable load alone already exceeds the limits. +Process transfer counters do not measure physical-disk bandwidth. + +The active power plan was already High performance, with the AC processor minimum +and maximum set to 100%. CIM clock fields do not prove actual frequency or absence +of throttling. Windows reported no blocking restart or installer condition; the +46 pending-file-operation entries were stable deletion-only pairs accepted by the +existing policy, and Application event-log continuity passed. + +The final observation is retained in `runner-stability/host-findings.json`, with +raw telemetry, process CPU endpoints, policy/source hashes and system checks. +Current background activity explains why this host is unsuitable for a new timing +comparison; it does not prove which processes caused earlier benchmark variation. +All prior failed screens remain retained. All 23 guarded source/test files, five +unrelated files, and 253 prior diagnostic artifacts passed preservation checks. + +Further measurement waits for the busy applications to be paused or closed and +remaining `TextInputHost` activity to be rechecked. A fresh acceptable host window +is only admission to a continuously monitored, bounded identical-binary comparison; +it does not qualify product performance. No benchmark retry was started, no thresholds +were changed, and reader/pooled prepared ranges remain disabled. + +### Readiness Cleanup and Standalone Measurement Handoff + +After the user stopped Call of Duty and Claude, a fresh 45-second host observation +still failed admission. Observable external CPU averaged 1.73 cores. Windows +`TextInputHost` used 0.92 core and Edge processes approximately 0.47 core. The +monitor also observed `VBCSCompiler`, which is prohibited by the existing policy, +although its endpoint CPU counters did not increase during the window. All 39 +samples were contaminated. The raw window remains in `runner-stability-idle`. + +The idle VB/C# compiler server was shut down through `dotnet build-server shutdown +--vbcscompiler`. With explicit user approval, the exact observed `TextInputHost` +process was stopped after checking its creation time, executable path, signature, +interactive session and owner. A replacement Windows input-host process was later +observed; the stop script does not target replacements. The user also paused Edge. +No product source, durability setting, priority, affinity or host threshold changed. + +One frozen monitored attempt then stopped during admission, before any fixture or +measured workload started. Its six telemetry samples retained external CPU of +0.39-0.86 core and process read traffic of approximately 52-85 MB/s. Sustained I/O +crossed the existing 4 MiB/s gate. `count-monitored-repeatability/final-findings.json` +records valid evidence, failed host admission and zero benchmark workloads; runtime +validation invocations did not seed fixtures or measure operations. + +A separate bounded 12-second process CPU/I/O census then identified two processes +whose executable paths and product metadata identify the Codex desktop app. Together +they accounted for approximately 4.69 MB/s of process transfers, already above the +4 MiB/s policy threshold. All observable external processes totalled 4.89 MB/s in +that later window. The [Windows process-I/O API](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-getprocessiocounters) +accounts for all process I/O; these are not physical-disk bandwidth measurements. +Similar read/write rates do not prove a specific IPC connection, and this later +window does not attribute the earlier 52-85 MB/s bursts to Codex. No application +was exempted from the policy to make admission pass. + +The first independent PowerShell launcher was prepared in +`count-unattended-repeatability/Run-Unattended.ps1`. It waits 60 seconds for the user +to fully exit Codex, refuses to start while Codex desktop processes remain, and +then applies a fresh 45-second monitored admission window. Only an accepted host +can start the same four serial nine-case processes, retaining 147,456 measured +cycles and 18,432 warmups if all finish. The original harness, 18 runtime DLLs, +fixtures, timing gates and 240-second child timeouts remain unchanged. Monitoring +continues throughout; any failure stops the attempt without retries. Offline +analysis and integrity manifests are generated after the run. This prepared run +does not qualify performance, clear earlier failures or enable reader ranges. + +The user's first launch stopped after the countdown because 14 processes still +had the Codex desktop executable path. It started no monitored admission or +benchmark workload. Two further invocations correctly refused to reuse the same +output directory. The original launcher, receipt and all 65 files in that attempt +remain byte-for-byte intact. + +The replacement entrypoint is +`.tmp/reader-snapshot-catalog-20261004/Run-CountRepeatability.ps1`. Each manual +invocation creates a fresh sibling directory from the validated frozen inputs. +It waits for the user to exit Codex and press Enter, records any remaining desktop +processes and returns to the same prompt; Q cancels. An exclusive file lock prevents +overlapping invocations. The measurement runner, analyzer, finalizer, runtime +binaries and host policy are unchanged; a failed workload is never automatically +retried. Preparation and isolated launcher tests exercised preservation, input +validation, cancellation, desktop reappearance and lock behavior without running +benchmarks. The full interactive measurement remains pending. Repair evidence is +in `launcher-repair-20261004`; no product performance claim changes. + +The user's next independent launch, saved in +`count-unattended-attempt-20261004-200941-738-2bad90aa`, passed the initial host +admission. `control-forward` completed all nine cases. `candidate-reverse` +completed eight cases and entered the final case before the monitor stopped it; +the remaining two processes never started. Five consecutive busy samples at +20:14:04-20:14:08 UTC triggered the unchanged policy. Across those samples, +external CPU ranged from 0.70 to 1.82 cores; combined process I/O reached +11.21 MB/s. The stop sample reported 1.32 external cores and 5.71 MB/s, above +the 0.5-core and 4 MiB/s limits. No prohibited process or servicing issue was +reported, and monitoring completed normally with 209 samples and no gap above +1.17 seconds. + +The attempt's offline validator passed; a subsequent review verified all 125 +artifact-manifest entries, launcher integrity hashes and frozen inputs. This +confirms a preserved stopped attempt, not a completed timing comparison. The +current Codex process tree started after launcher completion. Aggregate telemetry +does not identify the process responsible for the earlier burst, so process-level +attribution is needed before another benchmark attempt. No timing results were +promoted, thresholds relaxed, product files changed or workloads rerun during +this review. Review evidence is in `count-attempt-review-20261004-200941`. + +### Complete Repeatability Run with Background Activity Allowed + +At the user's explicit direction, the full four-process comparison ran without +desktop shutdown, idle admission or interruption for background CPU/I/O. This is +a separate diagnostic in `count-background-tolerant-20261004-202439`; the earlier +gated attempts and their evidence remain intact. The frozen harness, identical +18-DLL runtime sets, nine cases, 512 warmups and 4,096 measurements per case, +serial run order, durability, priority, affinity and 240-second process timeouts +were retained. No background processes were stopped. + +All four processes exited successfully. The unchanged analyzer validated 147,456 +measured cycles, 18,432 warmups, 144 phase metric rows, raw sample hashes, +checksums, allocation accounting and reader cleanup. The saved manifest covers +302 input/output files and was independently rechecked. Measurement took about +6 minutes 37 seconds; validation and closeout brought the total to 7 minutes +16 seconds. + +There were no median or allocation flags. The same 32-table `commit-create` +case exceeded the repeated P99 threshold in its nested commit, write and total +phases. These three flags describe one case, not three independent failures: + +| Phase | Pair 1 control / candidate P99 | Pair 2 control / candidate P99 | +|---|---:|---:| +| Commit | 2.892 / 5.639 ms | 2.595 / 5.626 ms | +| Write, including commit | 3.032 / 5.711 ms | 2.660 / 5.669 ms | +| Total | 3.064 / 5.727 ms | 2.680 / 5.680 ms | + +For the one-table COUNT reader phase, the four runs had medians of 7.2-11.7 us, +P99 of 37.5-49.8 us, and identical allocation of 903.4375 bytes per operation. +The labels compare identical binaries, so their differences cannot establish a +code-change speedup or regression. The comparison's timing flag remains visible +(`passed=false`, analyzer exit 1) while measurement integrity passed +(`evidenceValid=true`). This completed diagnostic does not clear earlier failed +screens or enable reader/pooled prepared ranges. No product code changed. + --- # CSharpDB Benchmark Suite diff --git a/tests/CSharpDB.Benchmarks/Infrastructure/PerfEnvironment.cs b/tests/CSharpDB.Benchmarks/Infrastructure/PerfEnvironment.cs new file mode 100644 index 00000000..7f9486ca --- /dev/null +++ b/tests/CSharpDB.Benchmarks/Infrastructure/PerfEnvironment.cs @@ -0,0 +1,29 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Text.Json; + +namespace CSharpDB.Benchmarks.Infrastructure; + +internal static class PerfEnvironment +{ + // Capture the actual benchmark process runtime before normal benchmark + // dispatch. This command does not run or initialize any benchmark workload. + [ModuleInitializer] + internal static void CaptureIfRequested() + { + string[] args = Environment.GetCommandLineArgs(); + if (args.Length != 2 || args[1] != "--perf-environment") + return; + + Console.WriteLine(JsonSerializer.Serialize(new + { + frameworkDescription = RuntimeInformation.FrameworkDescription, + runtimeVersion = Environment.Version.ToString(), + processArchitecture = RuntimeInformation.ProcessArchitecture.ToString(), + targetFramework = typeof(PerfEnvironment).Assembly.GetCustomAttribute()!.FrameworkName, + })); + Environment.Exit(0); + } +} diff --git a/tests/CSharpDB.Benchmarks/Micro/PreparedQueryPlanReuseBenchmarks.cs b/tests/CSharpDB.Benchmarks/Micro/PreparedQueryPlanReuseBenchmarks.cs new file mode 100644 index 00000000..af662fe6 --- /dev/null +++ b/tests/CSharpDB.Benchmarks/Micro/PreparedQueryPlanReuseBenchmarks.cs @@ -0,0 +1,158 @@ +using BenchmarkDotNet.Attributes; +using CSharpDB.Data; + +namespace CSharpDB.Benchmarks.Micro; + +/// Exercises prepared-plan reuse while cycling parameter values across cache-size boundaries. +[MemoryDiagnoser] +[SimpleJob(warmupCount: 3, iterationCount: 10)] +public class PreparedQueryPlanReuseBenchmarks +{ + [Params(1, 64, 65, 256, 2048)] + public int ValueSetCount { get; set; } + + private CSharpDbConnection _connection = null!; + private CSharpDbCommand _lookup = null!; + private CSharpDbCommand _range = null!; + private CSharpDbCommand _primaryKey = null!; + private CSharpDbCommand _primaryKeyOnly = null!; + private CSharpDbCommand _primaryKeyRange = null!; + private CSharpDbParameter _lookupValue = null!; + private CSharpDbParameter _lower = null!; + private CSharpDbParameter _upper = null!; + private CSharpDbParameter _primaryKeyValue = null!; + private CSharpDbParameter _primaryKeyOnlyValue = null!; + private CSharpDbParameter _primaryKeyRangeLower = null!; + private CSharpDbParameter _primaryKeyRangeUpper = null!; + private int _lookupIndex; + private int _rangeIndex; + private int _primaryKeyIndex; + private int _primaryKeyOnlyIndex; + private int _primaryKeyRangeIndex; + + [GlobalSetup] + public async Task Setup() + { + _connection = new CSharpDbConnection("Data Source=:memory:;Pooling=false"); + await _connection.OpenAsync(); + using var setup = _connection.CreateCommand(); + setup.CommandText = "CREATE TABLE bench (id INTEGER PRIMARY KEY, value INTEGER)"; + await setup.ExecuteNonQueryAsync(); + await using (var transaction = await _connection.BeginTransactionAsync()) + { + setup.Transaction = transaction; + setup.CommandText = "INSERT INTO bench VALUES (@id, @value)"; + var id = setup.Parameters.AddWithValue("id", 0L); + var value = setup.Parameters.AddWithValue("value", 0L); + setup.Prepare(); + for (int i = 0; i < 10000; i++) + { + id.Value = value.Value = (long)i; + await setup.ExecuteNonQueryAsync(); + } + await transaction.CommitAsync(); + } + setup.Transaction = null; + setup.Parameters.Clear(); + setup.CommandText = "CREATE INDEX ix_bench_value ON bench(value)"; + await setup.ExecuteNonQueryAsync(); + + _lookup = _connection.CreateCommand(); + _lookup.CommandText = "SELECT id, value FROM bench WHERE value = @p"; + _lookupValue = _lookup.Parameters.AddWithValue("p", 0L); + _lookup.Prepare(); + _range = _connection.CreateCommand(); + _range.CommandText = "SELECT id, value FROM bench WHERE value >= @lower AND value < @upper ORDER BY value LIMIT 4"; + _lower = _range.Parameters.AddWithValue("lower", 0L); + _upper = _range.Parameters.AddWithValue("upper", 0L); + _range.Prepare(); + _primaryKey = _connection.CreateCommand(); + _primaryKey.CommandText = "SELECT id, value FROM bench WHERE id = @p"; + _primaryKeyValue = _primaryKey.Parameters.AddWithValue("p", 0L); + _primaryKey.Prepare(); + _primaryKeyOnly = _connection.CreateCommand(); + _primaryKeyOnly.CommandText = "SELECT id, id FROM bench WHERE id = @p"; + _primaryKeyOnlyValue = _primaryKeyOnly.Parameters.AddWithValue("p", 0L); + _primaryKeyOnly.Prepare(); + _primaryKeyRange = _connection.CreateCommand(); + _primaryKeyRange.CommandText = "SELECT id, value FROM bench WHERE id >= @lower AND id < @upper ORDER BY id LIMIT 4"; + _primaryKeyRangeLower = _primaryKeyRange.Parameters.AddWithValue("lower", 0L); + _primaryKeyRangeUpper = _primaryKeyRange.Parameters.AddWithValue("upper", 0L); + _primaryKeyRange.Prepare(); + } + + [GlobalCleanup] + public async Task Cleanup() + { + _lookup?.Dispose(); + _range?.Dispose(); + _primaryKey?.Dispose(); + _primaryKeyOnly?.Dispose(); + _primaryKeyRange?.Dispose(); + if (_connection is not null) await _connection.DisposeAsync(); + } + + [Benchmark(Description = "Prepared indexed equality, changing values")] + public Task IndexedEquality() + { + long value = 100L + 4 * _lookupIndex; + _lookupIndex = (_lookupIndex + 1) % ValueSetCount; + _lookupValue.Value = value; + return ReadAsync(_lookup, value, 1); + } + + [Benchmark(Description = "Prepared ordered range, changing values")] + public Task OrderedRange() + { + long value = 100L + 4 * _rangeIndex; + _rangeIndex = (_rangeIndex + 1) % ValueSetCount; + _lower.Value = value; + _upper.Value = value + 4; + return ReadAsync(_range, value, 4); + } + + [Benchmark(Description = "Prepared primary-key equality, changing values")] + public Task PrimaryKeyEquality() + { + long value = 100L + 4 * _primaryKeyIndex; + _primaryKeyIndex = (_primaryKeyIndex + 1) % ValueSetCount; + _primaryKeyValue.Value = value; + return ReadAsync(_primaryKey, value, 1); + } + + [Benchmark(Description = "Prepared primary-key-only projection, changing values")] + public Task PrimaryKeyOnlyProjection() + { + long value = 100L + 4 * _primaryKeyOnlyIndex; + _primaryKeyOnlyIndex = (_primaryKeyOnlyIndex + 1) % ValueSetCount; + _primaryKeyOnlyValue.Value = value; + return ReadAsync(_primaryKeyOnly, value, 1); + } + + [Benchmark(Description = "Prepared primary-key range, changing values")] + public Task PrimaryKeyRange() + { + long value = 100L + 4 * _primaryKeyRangeIndex; + _primaryKeyRangeIndex = (_primaryKeyRangeIndex + 1) % ValueSetCount; + _primaryKeyRangeLower.Value = value; + _primaryKeyRangeUpper.Value = value + 4; + return ReadAsync(_primaryKeyRange, value, 4); + } + + private static async Task ReadAsync(CSharpDbCommand command, long value, int expectedRows) + { + await using var reader = await command.ExecuteReaderAsync(); + long checksum = 0; + int row = 0; + while (await reader.ReadAsync()) + { + long id = reader.GetInt64(0); + if (id != value + row || reader.GetInt64(1) != value + row) + throw new InvalidOperationException("Prepared query returned an incorrect parameter value."); + checksum += id; + row++; + } + if (row != expectedRows) throw new InvalidOperationException("Prepared query returned an incorrect row count."); + return checksum; + } +} diff --git a/tests/CSharpDB.Benchmarks/README.md b/tests/CSharpDB.Benchmarks/README.md index e2c31e0d..2fbeec96 100644 --- a/tests/CSharpDB.Benchmarks/README.md +++ b/tests/CSharpDB.Benchmarks/README.md @@ -463,6 +463,67 @@ tag exists. A failed gate therefore leaves the version untagged and retryable. The command then creates the protected tag with the maintainer's authenticated Git identity and starts a resumable hosted publication of that exact bundle. +### Hosted guardrail evidence + +`Run-PairedPerfGuardrails.ps1` requires two clean, disposable product checkouts +and runs from a separate harness checkout. It copies that harness into both +products, builds both before measurement, then executes **base/head** followed +by **head/base**. The PR lane retains its nine checks and the release lane its +nineteen checks, including durable SQL batching and diagnostic suites. Product +build properties and packages remain revision-specific and have separate hash +manifests. The harness identity records source, project, script, and configuration +hashes separately from the product commit SHA. +The focused benchmark solution lists its product dependency closure so Release +builds retain Release configuration throughout that graph. + +Hosted micro runs use two warmups and three measured iterations of 100 ms; +diagnostic suite arguments and numeric thresholds are unchanged. Both revisions +and both orders must use identical command arguments, runtime identities, and +enforceable machine fingerprints. This shorter hosted sampling profile limits +measurement time while preserving configured row coverage; it is not a +dedicated-runner release qualification or a source for published performance tables. + +Every check must compare all of its `requiredRows`, or the entire identical +base/head key set when that list is absent, and meet its positive +`minimumEnforceableComparisons`. Required current-only rows remain required but +cannot satisfy paired comparison coverage. Skipped or compatible-machine rows +cannot make a check green. `-NoFailOnRegression` suppresses threshold failures; +missing baselines, manifests, fingerprints, runtime identity, CSVs, logs, rows, +nonnumeric metrics, zero executed cases, and incomplete coverage always fail. +The exported micro CSV row count must also match the BenchmarkDotNet executed +case count for each invocation. Duplicate exports cannot supply case coverage +for one invocation or be reused across invocations. A failed recheck replaces +the generated report with its current error instead of retaining a prior pass. + +Each capture contains `manifest.json`, `machine.json`, `runtime.json`, a harness +file manifest, and hashes for every retained artifact. Comparisons validate those +hashes and require every referenced CSV for both inputs. `Compare-Baseline.ps1` +requires `-CurrentSnapshot`; raw CSV directories cannot supply captured +provenance. Each product selects its own SDK through `global.json`; SDK changes +remain enforceable when the actual measured runtime identity matches. +Historical snapshots without this +provenance remain historical evidence and are rejected as guardrail baselines; +their missing metadata is never synthesized. An explicit `-BaselineSnapshot` +overrides historical per-check baseline choices. Fresh local captures can be +created with `Capture-Baseline.ps1` or `Run-Perf-Guardrails.ps1 -CaptureOnly`. + +```powershell +# Run from the harness checkout; base/head directories are disposable. +./tests/CSharpDB.Benchmarks/scripts/Run-PairedPerfGuardrails.ps1 ` + -BaseRoot C:/perf/base -HeadRoot C:/perf/head ` + -OutputPath C:/perf/evidence-new -Mode pr -NoFailOnRegression + +# Focused synthetic integrity, coverage, and paired-driver tests. +./tests/CSharpDB.Benchmarks/scripts/Test-PerfGuardrails.ps1 +``` + +The workflow uploads both repository-root and benchmark-directory +`BenchmarkDotNet.Artifacts`, per-run logs, raw and aggregate CSVs for all four +captures, the execution order, build-input manifests, and both order reports. +`Compare-PairedPerfGuardrails.ps1 -OutputPath ...` revalidates retained evidence +without rerunning measurements. Hosted runtime and job-duration verification +must still be performed on GitHub after review. + ### Optional local durable-write diagnostics During a new local diagnostic, the repository must remain clean because both @@ -603,9 +664,10 @@ When explicitly requested, the local wrapper can still publish a diagnostic That status is retained for audit and tooling compatibility only; the hosted release path does not read it. -The existing scheduled perf-guardrail workflow remains report-only and includes -the durable SQL batching baseline plus its configured micro and diagnostic -checks. The other supplemental two-revision suites remain selectable with +The scheduled perf-guardrail workflow reports timing regressions and always +fails invalid evidence. It compares the head product commit with its parent +(PRs use the PR base SHA; manual runs can select `base_ref`) in the same job. +The other supplemental two-revision suites remain selectable with `-SuiteName` (or together by omitting it) for manual investigation; they do not run again as part of SQL release qualification and do not block publishing. @@ -729,3 +791,27 @@ Related files: - `scripts/Test-PreviousReleasePerformance.ps1`: clean two-revision benchmark runner. - `scripts/Watch-LocalPerformanceEnvironment.ps1`: fail-closed observable external-load evidence monitor. - `scripts/Update-BenchmarkReadme.ps1`: generated-results updater. + +## Prepared SELECT plan reuse + +`PreparedQueryPlanReuseBenchmarks` exercises public ADO.NET prepared reads with 1, 64, 65, 256, and 2048 recurring parameter sets. Each execution validates a fixed one-row secondary-index or primary-key lookup, a decode-free primary-key projection, or a four-row ordered secondary-index or physical primary-key range. This covers changing values beyond the old 64-entry bound-statement cache, unlike stable-text plan-cache benchmarks. + +```powershell +dotnet run -c Release --project .\tests\CSharpDB.Benchmarks\CSharpDB.Benchmarks.csproj -- --micro --filter '*PreparedQueryPlanReuseBenchmarks*' +``` + +The prepared index path currently supports non-null integral parameters in comparisons against one indexed integer column, column/star projections, optional ascending order on that column, and constant LIMIT/OFFSET. Physical INTEGER/BIGINT primary-key equality and ranges also reuse plans, including additional bounds on the same key. Equality preserves cache-only and decode-free lookup paths; ranges seek to the lower bound and stop at the upper bound without requiring a secondary index. It caches up to 256 template plans per planner, invalidates them on schema changes, and gives each execution its own parameter frame and operators. Different values do not consume additional prepared-plan entries. Unsupported shapes, parameter types, catalogs, and physical EXPLAIN capture use the ordinary planner; its value-bound cache remains limited to 64 entries. Prepared metadata reuse does not imply that every parameterized query uses this path. + +The physical primary-key range extension applies to direct database, explicit write-transaction and reader-session planners, including pooled ADO reads. `ReaderSession` uses frozen committed catalog metadata so roots, schemas, and counts remain consistent with its snapshot. The isolated local pooled-range comparison passed its median, allocation and P99 checks; earlier durable-write P99 diagnostics remain recorded separately. Existing prepared equality and secondary-index eligibility is unchanged. This benchmark fixture uses `Pooling=false`; see the October 4 isolated pooled-range comparison in [HISTORY.md](HISTORY.md) for the file-backed pooled measurements and their limits. Pooled queries create fresh reader planners, so their improvement comes from bounded primary-key access rather than sharing a cached plan across queries. A subsequent allocation profile found unnecessary next-leaf read-ahead after a range's upper bound; bounded cursors now avoid that speculative read while preserving traversal. The separate local follow-up in [HISTORY.md](HISTORY.md) records the short-range gains and larger-range regression checks. + +Snapshot pagers also avoid allocating unused writer diagnostics and allocation delegates. The October 4 snapshot-pager follow-up in [HISTORY.md](HISTORY.md) records the isolated component savings and paired pooled-query results, including the modest timing changes and qualification limits. + +Reader planners allocate cache containers and adaptive diagnostics only when used. The October 4 planner-setup follow-up in [HISTORY.md](HISTORY.md) records a further 10.01% allocation reduction and 5.36% lower observed median latency for four-row pooled prepared ranges. Larger ranges, arithmetic projections, and self joins passed the declared regression checks; individual tail timings still varied. Planners remain bound to individual reader snapshots. The same validation found and fixed stale trigger bodies after replacement when fast inserts bypassed the normal schema-cache check. + +Ordinary SELECTs with arithmetic projections and join-local predicates can also use bounded physical integer primary-key scans after existing lookup selection fails. Residual filters run before sorting and LIMIT. The October 4 ordinary-range follow-up in [HISTORY.md](HISTORY.md) measured 94.91% lower median latency and 89.32% less allocation for its four-row arithmetic/join fixtures. Two prepared controls exceeded the original median regression limit in one pair; a separate focused comparison did not reproduce a persistent slowdown. Both results and their limits are retained. + +The lazy-table-scan change committed in `d55c5d98` defers 64-row buffers until batch access. The October 4 follow-up in [HISTORY.md](HISTORY.md) records a further 12.89% allocation reduction for its small expression/join fixtures. The original comparison and focused check failed latency checks. A later three-arm diagnostic with matched compiler inputs, one case per process, and a shared fixture passed its fixed checks: the large prepared-range slowdown did not reproduce, and the expression-only allocation reduction was about 13.34%. The earlier cause remains unresolved, and this limited diagnostic does not establish a latency improvement or release-wide qualification. + +A subsequent wide-range profile led to repairing unreachable whole-batch forwarding in `LimitOperator`. Owned fallback buffers remain separate from borrowed batches, preserving retention and partial-tail behavior. The October 4 LIMIT follow-up in [HISTORY.md](HISTORY.md) records 7.02% lower observed median latency for 4,096-row pooled primary-key reads across two pairs. All nine cases passed the fixed regression checks; individual P99 measurements still varied. The result is local evidence for this bounded change, with release-wide qualification still open. + +For nullable indexed columns, supported comparison predicates exclude NULL rows, allowing ordered index access without losing matching rows. Broad equality values still consult the existing selectivity guard. Tiny LIMIT windows without an offset use the row boundary, and index-scan/limit batch buffers are allocated when requested. The fixture uses an in-memory database; use controlled file-backed paired runs when qualifying a release, and distinguish improvements from plan reuse from improvements in the chosen access path and result buffering. diff --git a/tests/CSharpDB.Benchmarks/perf-thresholds-pr.json b/tests/CSharpDB.Benchmarks/perf-thresholds-pr.json index 44e8e697..dc4b579a 100644 --- a/tests/CSharpDB.Benchmarks/perf-thresholds-pr.json +++ b/tests/CSharpDB.Benchmarks/perf-thresholds-pr.json @@ -7,6 +7,7 @@ }, "checks": [ { + "minimumEnforceableComparisons": 1, "csv": "CSharpDB.Benchmarks.Micro.PointLookupGuardrailBenchmarks-report.csv", "baselineSnapshot": "focused-validation/20260330-122507", "keyColumns": [ @@ -24,6 +25,7 @@ ] }, { + "minimumEnforceableComparisons": 1, "csv": "CSharpDB.Benchmarks.Micro.JoinGuardrailBenchmarks-report.csv", "baselineSnapshot": "focused-validation/20260330-122507", "keyColumns": [ @@ -31,6 +33,7 @@ ] }, { + "minimumEnforceableComparisons": 1, "csv": "CSharpDB.Benchmarks.Micro.InsertGuardrailBenchmarks-report.csv", "baselineSnapshot": "focused-validation/20260330-122507", "keyColumns": [ @@ -52,6 +55,7 @@ ] }, { + "minimumEnforceableComparisons": 1, "csv": "CSharpDB.Benchmarks.Micro.CollectionIndexGuardrailBenchmarks-report.csv", "baselineSnapshot": "focused-validation/20260330-122507", "keyColumns": [ @@ -62,6 +66,7 @@ "maxAllocRegressionBytes": 2048.0 }, { + "minimumEnforceableComparisons": 1, "csv": "CSharpDB.Benchmarks.Micro.CompositeIndexGuardrailBenchmarks-report.csv", "baselineSnapshot": "focused-validation/20260330-122507", "keyColumns": [ @@ -73,6 +78,7 @@ "maxAllocRegressionBytes": 262144.0 }, { + "minimumEnforceableComparisons": 1, "csv": "CSharpDB.Benchmarks.Micro.IndexAggregateGuardrailBenchmarks-report.csv", "baselineSnapshot": "focused-validation/20260330-122507", "keyColumns": [ @@ -82,6 +88,7 @@ "maxMeanRegressionPercent": 25.0 }, { + "minimumEnforceableComparisons": 1, "csv": "CSharpDB.Benchmarks.Micro.QueryPlanCacheGuardrailBenchmarks-report.csv", "baselineSnapshot": "focused-validation/20260330-122507", "keyColumns": [ @@ -92,6 +99,7 @@ "maxAllocRegressionBytes": 131072.0 }, { + "minimumEnforceableComparisons": 1, "csv": "CSharpDB.Benchmarks.Micro.WalGuardrailBenchmarks-report.csv", "baselineSnapshot": "focused-validation/20260330-122507", "keyColumns": [ @@ -113,6 +121,7 @@ ] }, { + "minimumEnforceableComparisons": 1, "csv": "CSharpDB.Benchmarks.Micro.ScanProjectionGuardrailBenchmarks-report.csv", "baselineSnapshot": "focused-validation/20260330-122507", "keyColumns": [ diff --git a/tests/CSharpDB.Benchmarks/perf-thresholds.json b/tests/CSharpDB.Benchmarks/perf-thresholds.json index 389c9822..ef612831 100644 --- a/tests/CSharpDB.Benchmarks/perf-thresholds.json +++ b/tests/CSharpDB.Benchmarks/perf-thresholds.json @@ -7,6 +7,7 @@ }, "checks": [ { + "minimumEnforceableComparisons": 1, "csv": "CSharpDB.Benchmarks.Micro.CollationIndexBenchmarks-report.csv", "baselineSnapshot": "focused-validation/20260330-122507", "keyColumns": [ @@ -111,6 +112,7 @@ ] }, { + "minimumEnforceableComparisons": 1, "csv": "CSharpDB.Benchmarks.Micro.InsertBenchmarks-report.csv", "baselineSnapshot": "focused-validation/20260413-050945", "keyColumns": [ @@ -133,6 +135,7 @@ ] }, { + "minimumEnforceableComparisons": 1, "csv": "CSharpDB.Benchmarks.Micro.PointLookupBenchmarks-report.csv", "baselineSnapshot": "focused-validation/20260330-122507", "keyColumns": [ @@ -183,6 +186,7 @@ ] }, { + "minimumEnforceableComparisons": 1, "csv": "CSharpDB.Benchmarks.Micro.CollectionIndexBenchmarks-report.csv", "baselineSnapshot": "focused-validation/20260413-050945", "keyColumns": [ @@ -210,6 +214,7 @@ "maxAllocRegressionBytes": 2048.0 }, { + "minimumEnforceableComparisons": 1, "csv": "CSharpDB.Benchmarks.Micro.JoinBenchmarks-report.csv", "baselineSnapshot": "focused-validation/20260330-122507", "keyColumns": [ @@ -226,6 +231,7 @@ ] }, { + "minimumEnforceableComparisons": 1, "csv": "CSharpDB.Benchmarks.Micro.ScanProjectionBenchmarks-report.csv", "baselineSnapshot": "focused-validation/20260330-122507", "keyColumns": [ @@ -301,6 +307,7 @@ "maxMeanRegressionPercent": 25.0 }, { + "minimumEnforceableComparisons": 1, "csv": "CSharpDB.Benchmarks.Micro.OrderByIndexBenchmarks-report.csv", "baselineSnapshot": "focused-validation/20260330-122507", "keyColumns": [ @@ -324,6 +331,7 @@ "maxMeanRegressionPercent": 25.0 }, { + "minimumEnforceableComparisons": 1, "csv": "CSharpDB.Benchmarks.Micro.IndexAggregateBenchmarks-report.csv", "baselineSnapshot": "focused-validation/20260330-122507", "keyColumns": [ @@ -351,6 +359,7 @@ "maxMeanRegressionPercent": 25.0 }, { + "minimumEnforceableComparisons": 1, "csv": "CSharpDB.Benchmarks.Micro.ScalarAggregateBenchmarks-report.csv", "baselineSnapshot": "focused-validation/20260330-122507", "keyColumns": [ @@ -376,6 +385,7 @@ ] }, { + "minimumEnforceableComparisons": 1, "csv": "CSharpDB.Benchmarks.Micro.ScalarAggregateLookupBenchmarks-report.csv", "baselineSnapshot": "focused-validation/20260418-185724", "keyColumns": [ @@ -385,6 +395,7 @@ "maxMeanRegressionPercent": 15.0 }, { + "minimumEnforceableComparisons": 1, "csv": "CSharpDB.Benchmarks.Micro.DistinctBenchmarks-report.csv", "baselineSnapshot": "focused-validation/20260330-122507", "keyColumns": [ @@ -414,6 +425,7 @@ "maxAllocRegressionBytes": 131072.0 }, { + "minimumEnforceableComparisons": 1, "csv": "CSharpDB.Benchmarks.Micro.QueryPlanCacheBenchmarks-report.csv", "baselineSnapshot": "focused-validation/20260330-122507", "keyColumns": [ @@ -439,6 +451,7 @@ "maxAllocRegressionBytes": 131072.0 }, { + "minimumEnforceableComparisons": 1, "csv": "CSharpDB.Benchmarks.Micro.CompositeIndexBenchmarks-report.csv", "baselineSnapshot": "focused-validation/20260330-122507", "keyColumns": [ @@ -476,6 +489,7 @@ "maxAllocRegressionBytes": 262144.0 }, { + "minimumEnforceableComparisons": 1, "csv": "CSharpDB.Benchmarks.Micro.TextIndexBenchmarks-report.csv", "baselineSnapshot": "focused-validation/20260330-122507", "keyColumns": [ @@ -505,6 +519,7 @@ "maxAllocRegressionBytes": 65536.0 }, { + "minimumEnforceableComparisons": 1, "csv": "write-diagnostics-median-of-3.csv", "baselineSnapshot": "focused-validation/20260330-122507", "baselineSubDir": "macro-stress-scaling", @@ -536,6 +551,7 @@ "outputPattern": "write-diagnostics-*-median-of-3.csv" }, { + "minimumEnforceableComparisons": 1, "csv": "durable-sql-batching-median-of-3.csv", "baselineSnapshot": "focused-validation/20260330-122507", "baselineSubDir": "macro-stress-scaling", @@ -584,6 +600,7 @@ "outputPattern": "durable-sql-batching-*-median-of-3.csv" }, { + "minimumEnforceableComparisons": 1, "csv": "write-transaction-diagnostics-median-of-3.csv", "baselineSnapshot": "focused-validation/20260413-033723", "baselineSubDir": "macro-stress-scaling", @@ -626,6 +643,7 @@ "outputPattern": "write-transaction-diagnostics-*-median-of-3.csv" }, { + "minimumEnforceableComparisons": 1, "csv": "commit-fan-in-diagnostics.csv", "baselineSnapshot": "focused-validation/20260330-122507", "baselineSubDir": "macro-stress-scaling", @@ -669,6 +687,7 @@ "outputPattern": "commit-fan-in-diagnostics-*.csv" }, { + "minimumEnforceableComparisons": 1, "csv": "insert-fan-in-diagnostics.csv", "baselineSnapshot": "focused-validation/20260412-172336", "baselineSubDir": "macro-stress-scaling", diff --git a/tests/CSharpDB.Benchmarks/scripts/Capture-Baseline.ps1 b/tests/CSharpDB.Benchmarks/scripts/Capture-Baseline.ps1 index 40bc8c82..cac5ec40 100644 --- a/tests/CSharpDB.Benchmarks/scripts/Capture-Baseline.ps1 +++ b/tests/CSharpDB.Benchmarks/scripts/Capture-Baseline.ps1 @@ -44,6 +44,7 @@ Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +. (Join-Path $scriptDir 'Perf-Evidence.ps1') $benchDir = (Resolve-Path (Join-Path $scriptDir "..")).Path $repoRoot = (Resolve-Path (Join-Path $benchDir "..\\..")).Path @@ -54,120 +55,14 @@ if ([string]::IsNullOrWhiteSpace($OutputRoot)) $runTimestamp = (Get-Date).ToUniversalTime().ToString("yyyyMMdd-HHmmss") $snapshotDir = Join-Path $OutputRoot $runTimestamp +if (Test-Path -LiteralPath $snapshotDir) { throw "Snapshot output already exists: $snapshotDir" } New-Item -ItemType Directory -Path $snapshotDir -Force | Out-Null $benchmarkProject = Join-Path $benchDir "CSharpDB.Benchmarks.csproj" $startUtc = (Get-Date).ToUniversalTime() -function Get-OsPlatformName -{ - if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) - { - return "Windows" - } - - if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Linux)) - { - return "Linux" - } - - if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::OSX)) - { - return "macOS" - } - - return "Unknown" -} - -function Get-ProcessorName -{ - $cpuName = "" - - try - { - if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows) -and - (Get-Command Get-CimInstance -ErrorAction SilentlyContinue)) - { - $cpuName = [string](Get-CimInstance Win32_Processor | Select-Object -First 1 -ExpandProperty Name) - } - } - catch - { - } - - if ([string]::IsNullOrWhiteSpace($cpuName)) - { - try - { - if (Get-Command lscpu -ErrorAction SilentlyContinue) - { - $modelLine = & lscpu 2>$null | Where-Object { $_ -match "^Model name:\s*(.+)$" } | Select-Object -First 1 - if ($modelLine -match "^Model name:\s*(.+)$") - { - $cpuName = [string]$matches[1] - } - } - } - catch - { - } - } - - if ([string]::IsNullOrWhiteSpace($cpuName)) - { - try - { - if (Get-Command sysctl -ErrorAction SilentlyContinue) - { - $cpuName = [string]((& sysctl -n machdep.cpu.brand_string 2>$null) | Select-Object -First 1) - } - } - catch - { - } - } - - if ([string]::IsNullOrWhiteSpace($cpuName)) - { - $cpuName = [string]$env:PROCESSOR_IDENTIFIER - } - - return $cpuName.Trim() -} - -function Get-MachineFingerprint -{ - $dotnetVersion = "" - try - { - $dotnetVersion = [string]((& dotnet --version) | Select-Object -First 1) - } - catch - { - } - - $dotnetVersion = $dotnetVersion.Trim() - $dotnetMajorMinor = "" - if ($dotnetVersion -match "^(?\d+)\.(?\d+)") - { - $dotnetMajorMinor = "$($matches.major).$($matches.minor)" - } - - return [ordered]@{ - fingerprintVersion = 1 - capturedUtc = (Get-Date).ToUniversalTime().ToString("o") - runnerId = [string]$env:CSHARPDB_PERF_RUNNER_ID - machineName = [Environment]::MachineName - cpuName = Get-ProcessorName - logicalCoreCount = [Environment]::ProcessorCount - osPlatform = Get-OsPlatformName - osDescription = [System.Runtime.InteropServices.RuntimeInformation]::OSDescription.Trim() - osArchitecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() - processArchitecture = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString() - dotnetVersion = $dotnetVersion - dotnetMajorMinor = $dotnetMajorMinor - } -} +$script:CaptureRuns = [Collections.Generic.List[object]]::new() +New-Item -ItemType Directory -Path (Join-Path $snapshotDir 'logs') -Force | Out-Null function Invoke-BenchmarkRun { @@ -178,11 +73,25 @@ function Invoke-BenchmarkRun Write-Host "" Write-Host "=== $Label ===" - & dotnet run -c $Configuration --project $benchmarkProject -- @Arguments - if ($LASTEXITCODE -ne 0) - { - throw "Benchmark step failed: $Label" + $runStart = [DateTime]::UtcNow + $relativeLog = 'logs/{0:D2}.log' -f ($script:CaptureRuns.Count + 1) + $logPath = Join-Path $snapshotDir $relativeLog + Push-Location $benchDir + try { + & dotnet run -c $Configuration --project $benchmarkProject -- @Arguments 2>&1 | Tee-Object -FilePath $logPath + if ($LASTEXITCODE -ne 0) { throw "Benchmark step failed: $Label" } + } + finally { Pop-Location } + if ($Arguments -contains '--micro') { $count = Get-PerfExecutedMicroCases $logPath } + else { + $count = 0 + $resultsDir = Join-Path $benchDir "bin/$Configuration/net10.0/results" + foreach ($csv in @(Get-ChildItem -LiteralPath $resultsDir -Filter '*.csv' -File | Where-Object LastWriteTimeUtc -ge $runStart)) { + $count += @(Import-Csv -LiteralPath $csv.FullName).Count + } + if ($count -le 0) { throw "Benchmark step produced zero executed cases: $Label" } } + $script:CaptureRuns.Add([pscustomobject]@{ label = $Label; arguments = $Arguments; executedCases = $count; logPath = $relativeLog }) } function Get-LatestArtifactSince @@ -394,16 +303,25 @@ function Copy-NewArtifacts $bdnRoot = Join-Path $repoRoot "BenchmarkDotNet.Artifacts" $benchResults = Join-Path $benchDir ("bin/{0}/net10.0/results" -f $Configuration) -$copiedMicroCsv = Copy-NewArtifacts -SourceDir (Join-Path $bdnRoot "results") -TargetSubDir "micro-results" -Pattern "*.csv" -$copiedMicroLogs = Copy-NewArtifacts -SourceDir $bdnRoot -TargetSubDir "micro-logs" -Pattern "*.log" +$copiedMicroCsv = 0 +$copiedMicroLogs = 0 +foreach ($artifactRoot in @($bdnRoot, (Join-Path $benchDir 'BenchmarkDotNet.Artifacts'))) { + $copiedMicroCsv += Copy-NewArtifacts -SourceDir (Join-Path $artifactRoot 'results') -TargetSubDir 'micro-results' -Pattern '*.csv' + $copiedMicroLogs += Copy-NewArtifacts -SourceDir $artifactRoot -TargetSubDir 'micro-logs' -Pattern '*.log' +} + $copiedMacro = Copy-NewArtifacts -SourceDir $benchResults -TargetSubDir "macro-stress-scaling" -Pattern "*.csv" $metadataPath = Join-Path $snapshotDir "metadata.txt" $machineFingerprintPath = Join-Path $snapshotDir "machine.json" $gitHead = (& git -C $repoRoot rev-parse HEAD).Trim() $gitStatus = & git -C $repoRoot status --short -$dotnetInfo = & dotnet --info -$machineFingerprint = Get-MachineFingerprint +Push-Location $benchDir +try { + $dotnetInfo = & dotnet --info + $machineFingerprint = Get-PerfMachineFingerprint +} +finally { Pop-Location } @( "UTC Timestamp: $runTimestamp" @@ -433,4 +351,6 @@ $machineFingerprint = Get-MachineFingerprint $machineFingerprint | ConvertTo-Json -Depth 4 | Set-Content -Path $machineFingerprintPath -Encoding UTF8 Write-Host "" +$runtime = Get-PerfRuntimeIdentity -Project $benchmarkProject -Configuration $Configuration +Write-PerfSnapshotManifest -SnapshotDir $snapshotDir -ProductCommit $gitHead -HarnessRoot $benchDir -Runtime $runtime -Runs @($script:CaptureRuns) Write-Host "Baseline snapshot written to: $snapshotDir" diff --git a/tests/CSharpDB.Benchmarks/scripts/Compare-Baseline.ps1 b/tests/CSharpDB.Benchmarks/scripts/Compare-Baseline.ps1 index d589f557..36dcc422 100644 --- a/tests/CSharpDB.Benchmarks/scripts/Compare-Baseline.ps1 +++ b/tests/CSharpDB.Benchmarks/scripts/Compare-Baseline.ps1 @@ -2,6 +2,7 @@ param( [string]$BaselineSnapshot = "", [string]$CurrentMicroResultsDir = "", + [string]$CurrentSnapshot = "", [string]$ThresholdsPath = "", [switch]$NoFailOnRegression, [string]$ReportPath = "" @@ -25,7 +26,7 @@ function Get-OptionalProperty if ($Object.PSObject.Properties.Name -contains $Name) { - return $Object.$Name + return ,$Object.$Name } return $DefaultValue @@ -43,124 +44,6 @@ function Normalize-ComparableText return (($Value.Trim().ToLowerInvariant()) -replace "\s+", " ") } -function Get-OsPlatformName -{ - if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) - { - return "Windows" - } - - if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Linux)) - { - return "Linux" - } - - if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::OSX)) - { - return "macOS" - } - - return "Unknown" -} - -function Get-ProcessorName -{ - $cpuName = "" - - try - { - if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows) -and - (Get-Command Get-CimInstance -ErrorAction SilentlyContinue)) - { - $cpuName = [string](Get-CimInstance Win32_Processor | Select-Object -First 1 -ExpandProperty Name) - } - } - catch - { - } - - if ([string]::IsNullOrWhiteSpace($cpuName)) - { - try - { - if (Get-Command lscpu -ErrorAction SilentlyContinue) - { - $modelLine = & lscpu 2>$null | Where-Object { $_ -match "^Model name:\s*(.+)$" } | Select-Object -First 1 - if ($modelLine -match "^Model name:\s*(.+)$") - { - $cpuName = [string]$matches[1] - } - } - } - catch - { - } - } - - if ([string]::IsNullOrWhiteSpace($cpuName)) - { - try - { - if (Get-Command sysctl -ErrorAction SilentlyContinue) - { - $cpuName = [string]((& sysctl -n machdep.cpu.brand_string 2>$null) | Select-Object -First 1) - } - } - catch - { - } - } - - if ([string]::IsNullOrWhiteSpace($cpuName)) - { - $cpuName = [string]$env:PROCESSOR_IDENTIFIER - } - - return $cpuName.Trim() -} - -function Get-DotnetMajorMinor -{ - param([Parameter(Mandatory = $false)][AllowNull()][AllowEmptyString()][string]$Version) - - $trimmed = [string]$Version - if ($trimmed -match "^(?\d+)\.(?\d+)") - { - return "$($matches.major).$($matches.minor)" - } - - return "" -} - -function Get-MachineFingerprint -{ - $dotnetVersion = "" - try - { - $dotnetVersion = [string]((& dotnet --version) | Select-Object -First 1) - } - catch - { - } - - $dotnetVersion = $dotnetVersion.Trim() - - return [pscustomobject]@{ - fingerprintVersion = 1 - capturedUtc = (Get-Date).ToUniversalTime().ToString("o") - runnerId = [string]$env:CSHARPDB_PERF_RUNNER_ID - machineName = [Environment]::MachineName - cpuName = Get-ProcessorName - logicalCoreCount = [Environment]::ProcessorCount - osPlatform = Get-OsPlatformName - osDescription = [System.Runtime.InteropServices.RuntimeInformation]::OSDescription.Trim() - osArchitecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() - processArchitecture = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString() - dotnetVersion = $dotnetVersion - dotnetMajorMinor = Get-DotnetMajorMinor -Version $dotnetVersion - } -} - function Get-SnapshotMachineFingerprint { param([Parameter(Mandatory = $true)][string]$SnapshotDir) @@ -187,7 +70,7 @@ function Format-MachineSummary $cores = [string](Get-OptionalProperty -Object $Fingerprint -Name "logicalCoreCount" -DefaultValue "?") $os = [string](Get-OptionalProperty -Object $Fingerprint -Name "osDescription" -DefaultValue "") $dotnet = [string](Get-OptionalProperty -Object $Fingerprint -Name "dotnetVersion" -DefaultValue "") - return "$runnerDisplay; cpu=$cpuName; cores=$cores; os=$os; dotnet=$dotnet" + return "$runnerDisplay; cpu=$cpuName; cores=$cores; os=$os; sdk=$dotnet" } function Get-MachineCompatibility @@ -199,12 +82,7 @@ function Get-MachineCompatibility if ($null -eq $BaselineFingerprint) { - return [pscustomobject]@{ - Classification = "legacy" - Enforcement = "strict" - Summary = "Strict (legacy baseline without machine fingerprint)" - Notes = "Baseline snapshot has no machine.json; retaining strict comparison." - } + throw 'Baseline snapshot has no machine fingerprint.' } $baselineRunnerId = Normalize-ComparableText ([string](Get-OptionalProperty -Object $BaselineFingerprint -Name "runnerId" -DefaultValue "")) @@ -225,16 +103,12 @@ function Get-MachineCompatibility (Normalize-ComparableText ([string](Get-OptionalProperty -Object $CurrentFingerprint -Name "osArchitecture" -DefaultValue ""))) $sameProcessArchitecture = (Normalize-ComparableText ([string](Get-OptionalProperty -Object $BaselineFingerprint -Name "processArchitecture" -DefaultValue ""))) -eq (Normalize-ComparableText ([string](Get-OptionalProperty -Object $CurrentFingerprint -Name "processArchitecture" -DefaultValue ""))) - $baselineDotnetMajorMinor = Normalize-ComparableText (Get-DotnetMajorMinor -Version ([string](Get-OptionalProperty -Object $BaselineFingerprint -Name "dotnetMajorMinor" -DefaultValue (Get-OptionalProperty -Object $BaselineFingerprint -Name "dotnetVersion" -DefaultValue "")))) - $currentDotnetMajorMinor = Normalize-ComparableText (Get-DotnetMajorMinor -Version ([string](Get-OptionalProperty -Object $CurrentFingerprint -Name "dotnetMajorMinor" -DefaultValue (Get-OptionalProperty -Object $CurrentFingerprint -Name "dotnetVersion" -DefaultValue "")))) - $sameDotnetMajorMinor = (-not [string]::IsNullOrWhiteSpace($baselineDotnetMajorMinor)) -and ($baselineDotnetMajorMinor -eq $currentDotnetMajorMinor) $sameOsDescription = (Normalize-ComparableText ([string](Get-OptionalProperty -Object $BaselineFingerprint -Name "osDescription" -DefaultValue ""))) -eq (Normalize-ComparableText ([string](Get-OptionalProperty -Object $CurrentFingerprint -Name "osDescription" -DefaultValue ""))) $sameHardware = $sameCpu -and $sameCoreCount -and $sameOsPlatform -and $sameOsArchitecture -and $sameProcessArchitecture - $sameRuntimeFamily = $sameDotnetMajorMinor - if ($sameRunnerId -or (($sameMachineName -and $sameOsDescription) -and $sameHardware -and $sameRuntimeFamily)) + if (($sameRunnerId -or ($sameMachineName -and $sameOsDescription)) -and $sameHardware) { return [pscustomobject]@{ Classification = "exact" @@ -244,7 +118,7 @@ function Get-MachineCompatibility } } - if ($sameHardware -and $sameRuntimeFamily) + if ($sameHardware) { return [pscustomobject]@{ Classification = "compatible" @@ -359,9 +233,11 @@ function Convert-ComparableNumber ) $text = $Value.Trim("'").Trim() -replace ",", "" - if ($text -match "^([+-]?[0-9]*\.?[0-9]+)") + if ($text -match "^([+-]?[0-9]*\.?[0-9]+)$") { - return [double]$matches[1] + $number = [double]$matches[1] + if (-not [double]::IsFinite($number)) { throw "Nonfinite numeric $Description value '$Value'." } + return $number } throw "Unable to parse numeric $Description value '$Value'." @@ -403,9 +279,7 @@ function Test-ExtraInfoChecks $currentRaw = Get-ExtraInfoValue -Row $CurrentRow -Key $key if ($null -eq $baselineRaw -or $null -eq $currentRaw) { - $regressed = $true - $notes.Add("ExtraInfo $key missing") | Out-Null - continue + throw "ExtraInfo $key missing in baseline or current results." } $baselineValue = Convert-ComparableNumber -Value $baselineRaw -Description "baseline ExtraInfo '$key'" @@ -516,9 +390,7 @@ function Test-CurrentExtraInfoChecks $currentRaw = Get-ExtraInfoValue -Row $CurrentRow -Key $key if ($null -eq $currentRaw) { - $regressed = $true - $notes.Add("ExtraInfo $key missing") | Out-Null - continue + throw "Required current ExtraInfo $key missing." } $currentValue = Convert-ComparableNumber -Value $currentRaw -Description "current ExtraInfo '$key'" @@ -527,9 +399,7 @@ function Test-CurrentExtraInfoChecks $maxRatioText = [string](Get-OptionalProperty -Object $check -Name "maxRatio" -DefaultValue "") if (-not [string]::IsNullOrWhiteSpace($minRatioText) -or -not [string]::IsNullOrWhiteSpace($maxRatioText)) { - $regressed = $true - $notes.Add("$key ratio check requires a baseline row") | Out-Null - continue + throw "$key ratio check requires a baseline row." } $minValueText = [string](Get-OptionalProperty -Object $check -Name "minValue" -DefaultValue "") @@ -786,9 +656,26 @@ function Add-ComparisonResult } $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +. (Join-Path $scriptDir 'Perf-Evidence.ps1') $benchDir = (Resolve-Path (Join-Path $scriptDir "..")).Path $repoRoot = (Resolve-Path (Join-Path $benchDir "..\\..")).Path +$reportWritten = $false +if (-not [string]::IsNullOrWhiteSpace($ReportPath)) { + if (-not [IO.Path]::IsPathRooted($ReportPath)) { $ReportPath = Join-Path $repoRoot $ReportPath } + New-Item -ItemType Directory -Path (Split-Path -Parent $ReportPath) -Force | Out-Null + @('# Performance Guardrail Report', '', 'Status: INCOMPLETE', '', 'Comparison has not completed.') | + Set-Content -LiteralPath $ReportPath -Encoding utf8 +} +trap { + if (-not $reportWritten -and -not [string]::IsNullOrWhiteSpace($ReportPath)) { + @('# Performance Guardrail Report', '', 'Status: INVALID', '', + "Generated (UTC): $([DateTime]::UtcNow.ToString('u'))", '', $_.Exception.Message) | + Set-Content -LiteralPath $ReportPath -Encoding utf8 + } + throw $_ +} + if ([string]::IsNullOrWhiteSpace($ThresholdsPath)) { $ThresholdsPath = Join-Path $benchDir "perf-thresholds.json" @@ -804,8 +691,16 @@ if (-not (Test-Path $ThresholdsPath)) } $config = Get-Content -Path $ThresholdsPath -Raw | ConvertFrom-Json +if (@($config.checks).Count -eq 0) { throw 'Guardrails require at least one configured check.' } +$explicitBaseline = -not [string]::IsNullOrWhiteSpace($BaselineSnapshot) +if ([string]::IsNullOrWhiteSpace($CurrentSnapshot)) { throw 'Current snapshot manifest and captured fingerprint are required, including in report mode. Use -CurrentSnapshot with a fresh capture.' } +$currentManifest = $null +if (-not [string]::IsNullOrWhiteSpace($CurrentSnapshot)) { + $CurrentSnapshot = (Resolve-Path -LiteralPath $CurrentSnapshot).Path + $currentManifest = Assert-PerfSnapshot $CurrentSnapshot +} $baselineRoot = Join-Path $benchDir "baselines" -$hasCheckBaselineOverrides = @( +$hasCheckBaselineOverrides = (-not $explicitBaseline) -and @( @($config.checks) | Where-Object { -not [string]::IsNullOrWhiteSpace([string](Get-OptionalProperty -Object $_ -Name "baselineSnapshot" -DefaultValue "")) } ).Count -gt 0 @@ -858,7 +753,7 @@ if (-not $baselineResolved -and $hasCheckBaselineOverrides) } elseif (-not $baselineResolved) { - Write-Host "No baseline snapshot found. Skipping comparison and reporting raw benchmark results only." + throw 'No baseline snapshot found. Evidence integrity requires a baseline even in report mode.' } $checksUsingDefaultCurrentResultsDir = @( @@ -867,7 +762,7 @@ $checksUsingDefaultCurrentResultsDir = @( ).Count -gt 0 $resolvedDefaultCurrentResultsDir = $null -if ($checksUsingDefaultCurrentResultsDir) +if ($checksUsingDefaultCurrentResultsDir -and $null -eq $currentManifest) { $resolvedDefaultCurrentResultsDir = Resolve-CurrentMicroResultsDirectory ` -BenchDir $benchDir ` @@ -876,7 +771,8 @@ if ($checksUsingDefaultCurrentResultsDir) } $reportCurrentLabel = -if ($checksUsingDefaultCurrentResultsDir) +if ($null -ne $currentManifest) { $CurrentSnapshot } +elseif ($checksUsingDefaultCurrentResultsDir) { if (@( @($config.checks) | @@ -895,56 +791,26 @@ else "" } -if (-not $baselineResolved -and -not $hasCheckBaselineOverrides) -{ - $summary = "No baseline snapshot available. Benchmark run completed but no regression comparison performed." - Write-Host $summary - - if (-not [string]::IsNullOrWhiteSpace($ReportPath)) - { - if (-not [System.IO.Path]::IsPathRooted($ReportPath)) - { - $ReportPath = Join-Path $repoRoot $ReportPath - } - - $reportDir = Split-Path -Parent $ReportPath - if (-not [string]::IsNullOrWhiteSpace($reportDir)) - { - New-Item -ItemType Directory -Path $reportDir -Force | Out-Null - } - - $lines = New-Object System.Collections.Generic.List[string] - $lines.Add("# Performance Guardrail Report") - $lines.Add("") - $lines.Add("- Baseline: **not available** (baselines directory is not present)") - $lines.Add("- Current: ``$reportCurrentLabel``") - $lines.Add("- Thresholds: ``$ThresholdsPath``") - $lines.Add("- Generated (UTC): $((Get-Date).ToUniversalTime().ToString('u'))") - $lines.Add("") - $lines.Add($summary) - $lines.Add("") - $lines.Add("To enable regression comparison, run ``Capture-Baseline.ps1`` on this machine and commit or make available the resulting snapshot.") - Set-Content -Path $ReportPath -Value $lines -Encoding UTF8 - Write-Host "Report written to $ReportPath" - } - - return -} - $defaults = Get-OptionalProperty -Object $config -Name "defaults" -DefaultValue $null $defaultMeanRegression = [double](Get-OptionalProperty -Object $defaults -Name "maxMeanRegressionPercent" -DefaultValue 8.0) $defaultAllocRegressionPct = [double](Get-OptionalProperty -Object $defaults -Name "maxAllocRegressionPercent" -DefaultValue 10.0) $defaultAllocRegressionBytes = [double](Get-OptionalProperty -Object $defaults -Name "maxAllocRegressionBytes" -DefaultValue 256.0) -$currentMachineFingerprint = Get-MachineFingerprint +$currentMachineFingerprint = $currentManifest.machine +Assert-PerfMachineFingerprint $currentMachineFingerprint $baselineMachineFingerprintCache = @{} +$snapshotManifestCache = @{} $machineCompatibilityCache = @{} $results = New-Object System.Collections.Generic.List[object] $failureCount = 0 +$enforceableCount = 0 foreach ($check in $config.checks) { $csvName = [string]$check.csv + $checkEnforceableCount = 0 + $minimumEnforceableComparisons = [int](Get-OptionalProperty $check 'minimumEnforceableComparisons' 1) + if ($minimumEnforceableComparisons -le 0) { throw "Check '$csvName' requires a positive minimumEnforceableComparisons." } $keyColumns = @($check.keyColumns) if ($keyColumns.Count -eq 0) { @@ -953,7 +819,7 @@ foreach ($check in $config.checks) $checkBaselineSnapshot = [string](Get-OptionalProperty -Object $check -Name "baselineSnapshot" -DefaultValue "") $checkBaselineSnapshotDir = $baselineSnapshotDir - if (-not [string]::IsNullOrWhiteSpace($checkBaselineSnapshot)) + if (-not $explicitBaseline -and -not [string]::IsNullOrWhiteSpace($checkBaselineSnapshot)) { $checkBaselineSnapshotPath = $checkBaselineSnapshot if (-not [System.IO.Path]::IsPathRooted($checkBaselineSnapshotPath)) @@ -967,7 +833,7 @@ foreach ($check in $config.checks) -Csv $csvName ` -Key "" ` -Enforcement "Strict" ` - -Status "FAIL" ` + -Status "INVALID" ` -Notes "Baseline snapshot missing: $checkBaselineSnapshotPath" $failureCount++ continue @@ -981,7 +847,7 @@ foreach ($check in $config.checks) -Csv $csvName ` -Key "" ` -Enforcement "Strict" ` - -Status "FAIL" ` + -Status "INVALID" ` -Notes "Global baseline snapshot missing for check without baselineSnapshot override" $failureCount++ continue @@ -989,7 +855,11 @@ foreach ($check in $config.checks) if (-not $baselineMachineFingerprintCache.ContainsKey($checkBaselineSnapshotDir)) { + $snapshotManifestCache[$checkBaselineSnapshotDir] = Assert-PerfSnapshot $checkBaselineSnapshotDir $baselineMachineFingerprintCache[$checkBaselineSnapshotDir] = Get-SnapshotMachineFingerprint -SnapshotDir $checkBaselineSnapshotDir + if ($null -ne $currentManifest -and $snapshotManifestCache[$checkBaselineSnapshotDir].harnessIdentity -cne $currentManifest.harnessIdentity) { + throw "Base/head harness identity mismatch for $csvName." + } } if (-not $machineCompatibilityCache.ContainsKey($checkBaselineSnapshotDir)) @@ -1011,6 +881,10 @@ foreach ($check in $config.checks) } $machineCompatibility = $machineCompatibilityCache[$checkBaselineSnapshotDir] + if ($null -ne $currentManifest -and + ($snapshotManifestCache[$checkBaselineSnapshotDir].runtime | ConvertTo-Json -Compress) -cne ($currentManifest.runtime | ConvertTo-Json -Compress)) { + throw "Base/head runtime identity mismatch for $csvName." + } $baselineSubDir = [string](Get-OptionalProperty -Object $check -Name "baselineSubDir" -DefaultValue "micro-results") $checkBaselineResultsDir = Join-Path $checkBaselineSnapshotDir $baselineSubDir @@ -1020,7 +894,7 @@ foreach ($check in $config.checks) -Csv $csvName ` -Key "" ` -Enforcement $machineCompatibility.Summary ` - -Status "FAIL" ` + -Status "INVALID" ` -Notes "Baseline results directory missing: $checkBaselineResultsDir" $failureCount++ continue @@ -1028,7 +902,11 @@ foreach ($check in $config.checks) $configuredCurrentResultsDir = [string](Get-OptionalProperty -Object $check -Name "currentResultsDir" -DefaultValue "") $checkCurrentResultsDir = - if ([string]::IsNullOrWhiteSpace($configuredCurrentResultsDir)) + if ($null -ne $currentManifest) + { + Join-Path $CurrentSnapshot $baselineSubDir + } + elseif ([string]::IsNullOrWhiteSpace($configuredCurrentResultsDir)) { $resolvedDefaultCurrentResultsDir } @@ -1043,7 +921,7 @@ foreach ($check in $config.checks) -Csv $csvName ` -Key "" ` -Enforcement $machineCompatibility.Summary ` - -Status "FAIL" ` + -Status "INVALID" ` -Notes "Current results directory missing: $checkCurrentResultsDir" $failureCount++ continue @@ -1051,6 +929,8 @@ foreach ($check in $config.checks) $baselineFile = Join-Path $checkBaselineResultsDir $csvName $currentFile = Join-Path $checkCurrentResultsDir $csvName + Assert-PerfSnapshotCsv -Manifest $snapshotManifestCache[$checkBaselineSnapshotDir] -SnapshotDir $checkBaselineSnapshotDir -CsvPath $baselineFile + if ($null -ne $currentManifest) { Assert-PerfSnapshotCsv -Manifest $currentManifest -SnapshotDir $CurrentSnapshot -CsvPath $currentFile } if (-not (Test-Path $baselineFile)) { @@ -1058,7 +938,7 @@ foreach ($check in $config.checks) -Csv $csvName ` -Key "" ` -Enforcement $machineCompatibility.Summary ` - -Status "FAIL" ` + -Status "INVALID" ` -Notes "Baseline CSV missing" $failureCount++ continue @@ -1070,14 +950,24 @@ foreach ($check in $config.checks) -Csv $csvName ` -Key "" ` -Enforcement $machineCompatibility.Summary ` - -Status "FAIL" ` + -Status "INVALID" ` -Notes "Current CSV missing" $failureCount++ continue } - $baselineRows = Import-Csv -Path $baselineFile - $currentRows = Import-Csv -Path $currentFile + $baselineRows = @(Import-Csv -Path $baselineFile) + $currentRows = @(Import-Csv -Path $currentFile) + if ($baselineRows.Count -eq 0 -or $currentRows.Count -eq 0) { throw "Zero rows in baseline or current CSV: $csvName" } + foreach ($rows in @(@{ label = 'baseline'; rows = $baselineRows }, @{ label = 'current'; rows = $currentRows })) { + $keys = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach ($row in $rows.rows) { + foreach ($column in $keyColumns) { + if ([string]::IsNullOrWhiteSpace([string](Get-OptionalProperty $row $column ''))) { throw "Missing key column $column in $($rows.label) $csvName." } + } + if (-not $keys.Add((Build-RowKey $row $keyColumns))) { throw "Duplicate row in $($rows.label) $csvName." } + } + } $requiredCurrentRows = Get-OptionalProperty -Object $check -Name "requiredCurrentRows" -DefaultValue @() foreach ($requiredCurrent in @($requiredCurrentRows)) @@ -1098,13 +988,16 @@ foreach ($check in $config.checks) -Csv $csvName ` -Key $requiredCurrentKey ` -Enforcement $machineCompatibility.Summary ` - -Status "FAIL" ` + -Status "INVALID" ` -Notes "Required current row missing" $failureCount++ continue } $currentOnlyExtraInfoChecks = Get-OptionalProperty -Object $requiredCurrent -Name "extraInfoChecks" -DefaultValue @() + $currentOnlyMetric = [string](Get-OptionalProperty $check 'metricColumn' 'Mean') + $currentOnlyValue = Convert-TimeToNanoseconds ([string](Get-OptionalProperty $currentMatch $currentOnlyMetric '')) + if ($currentOnlyValue -le 0 -or -not [double]::IsFinite($currentOnlyValue)) { throw "Invalid numeric $currentOnlyMetric in required current row $requiredCurrentKey." } $currentOnlyExtraInfoEvaluation = Test-CurrentExtraInfoChecks -CurrentRow $currentMatch -Checks $currentOnlyExtraInfoChecks $currentOnlyStatus = if ($machineCompatibility.Classification -eq "different") @@ -1149,6 +1042,11 @@ foreach ($check in $config.checks) $requiredRows = Get-OptionalProperty -Object $check -Name "requiredRows" -DefaultValue $null if ($null -eq $requiredRows) { + # Full-table checks require identical key sets, including head-only rows. + $baselineKeys = @($baselineRows | ForEach-Object { Build-RowKey $_ $keyColumns }) + foreach ($row in $currentRows) { + if ((Build-RowKey $row $keyColumns) -cnotin $baselineKeys) { throw "Row missing in baseline results: $csvName / $(Build-RowKey $row $keyColumns)" } + } foreach ($row in $baselineRows) { $rowsToCompare.Add($row) @@ -1165,7 +1063,7 @@ foreach ($check in $config.checks) -Csv $csvName ` -Key (Build-RowKey -Row $required -KeyColumns @($required.PSObject.Properties.Name)) ` -Enforcement $machineCompatibility.Summary ` - -Status "FAIL" ` + -Status "INVALID" ` -Notes "Required row missing in baseline" $failureCount++ continue @@ -1241,7 +1139,7 @@ foreach ($check in $config.checks) -BaselineMean ([string](Get-OptionalProperty -Object $baselineRow -Name $metricColumn -DefaultValue "")) ` -BaselineAlloc ([string](Get-OptionalProperty -Object $baselineRow -Name "Allocated" -DefaultValue "")) ` -Enforcement $machineCompatibility.Summary ` - -Status "FAIL" ` + -Status "INVALID" ` -Notes "Row missing in current results" $failureCount++ continue @@ -1260,7 +1158,7 @@ foreach ($check in $config.checks) -BaselineAlloc ([string](Get-OptionalProperty -Object $baselineRow -Name "Allocated" -DefaultValue "")) ` -CurrentAlloc ([string](Get-OptionalProperty -Object $currentRow -Name "Allocated" -DefaultValue "")) ` -Enforcement $machineCompatibility.Summary ` - -Status "FAIL" ` + -Status "INVALID" ` -Notes "Benchmark did not produce a numeric $metricColumn value" $failureCount++ continue @@ -1268,6 +1166,7 @@ foreach ($check in $config.checks) $baselineMeanNs = Convert-TimeToNanoseconds -Value $baselineMeanRaw $currentMeanNs = Convert-TimeToNanoseconds -Value $currentMeanRaw + if ($baselineMeanNs -le 0 -or $currentMeanNs -le 0 -or -not [double]::IsFinite($baselineMeanNs) -or -not [double]::IsFinite($currentMeanNs)) { throw "Invalid numeric $metricColumn in $csvName / $rowKey." } $meanDeltaPct = (($currentMeanNs - $baselineMeanNs) / $baselineMeanNs) * 100.0 $baselineAlloc = [string](Get-OptionalProperty -Object $baselineRow -Name "Allocated" -DefaultValue "") @@ -1285,7 +1184,7 @@ foreach ($check in $config.checks) -BaselineAlloc $baselineAlloc ` -CurrentAlloc $currentAlloc ` -Enforcement $machineCompatibility.Summary ` - -Status "FAIL" ` + -Status "INVALID" ` -Notes "Allocation column missing; set skipAllocationComparison=true for this check if alloc should not be compared" $failureCount++ continue @@ -1302,7 +1201,7 @@ foreach ($check in $config.checks) -BaselineAlloc $baselineAlloc ` -CurrentAlloc $currentAlloc ` -Enforcement $machineCompatibility.Summary ` - -Status "FAIL" ` + -Status "INVALID" ` -Notes "Benchmark did not produce a numeric Allocated value; set skipAllocationComparison=true for this check if alloc should not be compared" $failureCount++ continue @@ -1312,6 +1211,7 @@ foreach ($check in $config.checks) { $baselineAllocBytes = Convert-SizeToBytes -Value $baselineAlloc $currentAllocBytes = Convert-SizeToBytes -Value $currentAlloc + if (-not [double]::IsFinite($baselineAllocBytes) -or -not [double]::IsFinite($currentAllocBytes)) { throw "Invalid numeric allocation in $csvName / $rowKey." } $allocDeltaBytes = $currentAllocBytes - $baselineAllocBytes $allocDeltaPct = if ($baselineAllocBytes -eq 0.0) @@ -1332,6 +1232,12 @@ foreach ($check in $config.checks) } $extraInfoEvaluation = Test-ExtraInfoChecks -BaselineRow $baselineRow -CurrentRow $currentRow -Checks $extraInfoChecks + $hasMetric = (-not $skipMeanComparison) -or $allocComparisonEnabled -or @($extraInfoChecks).Count -gt 0 + if (-not $hasMetric) { throw "Check '$csvName' has no comparison metrics for $rowKey." } + if ($machineCompatibility.Enforcement -eq 'strict') { + $checkEnforceableCount++ + $enforceableCount++ + } $meanRegressed = (-not $skipMeanComparison) -and ($meanDeltaPct -gt $maxMeanRegression) $allocRegressed = $allocComparisonEnabled -and @@ -1395,13 +1301,18 @@ foreach ($check in $config.checks) -Status $status ` -Notes $machineNote } + if ($checkEnforceableCount -lt [Math]::Max($minimumEnforceableComparisons, $rowsToCompare.Count)) { + Add-ComparisonResult -Results $results -Csv $csvName -Key '' -Enforcement 'Strict' -Status 'INVALID' -Notes "Insufficient enforceable comparisons: $checkEnforceableCount. Every selected baseline row and at least $minimumEnforceableComparisons comparison(s) are required." + } } +$failureCount = @($results | Where-Object Status -eq "FAIL").Count $passCount = @($results | Where-Object Status -eq "PASS").Count $warnCount = @($results | Where-Object Status -eq "WARN").Count $skipCount = @($results | Where-Object Status -eq "SKIP").Count $rowCount = $results.Count -$summary = "Compared $rowCount rows against baseline. PASS=$passCount, WARN=$warnCount, SKIP=$skipCount, FAIL=$failureCount" +$integrityFailureCount = @($results | Where-Object Status -eq 'INVALID').Count +$summary = "Compared $rowCount rows against baseline. PASS=$passCount, WARN=$warnCount, SKIP=$skipCount, FAIL=$failureCount, INVALID=$integrityFailureCount, ENFORCEABLE=$enforceableCount" Write-Host $summary $results | Sort-Object Csv, Key | Format-Table Csv, Key, Metric, MeanDeltaPct, AllocDeltaPct, AllocDeltaBytes, Enforcement, Status -AutoSize @@ -1430,7 +1341,7 @@ if (-not [string]::IsNullOrWhiteSpace($ReportPath)) $lines.Add("- Thresholds: ``$ThresholdsPath``") $lines.Add("- Generated (UTC): $((Get-Date).ToUniversalTime().ToString('u'))") $lines.Add("- Current machine: ``$(Format-MachineSummary -Fingerprint $currentMachineFingerprint)``") - $lines.Add("- Machine policy: strict on matching perf runner or same-machine fingerprint; warn on compatible hardware/runtime; skip on materially different machines") + $lines.Add("- Machine policy: identical captured runtimes required; strict on matching perf runner or same-machine fingerprint; compatible/different machines cannot satisfy comparison coverage") $lines.Add("") $lines.Add($summary) $lines.Add("") @@ -1444,9 +1355,13 @@ if (-not [string]::IsNullOrWhiteSpace($ReportPath)) } Set-Content -Path $ReportPath -Value $lines -Encoding UTF8 + $reportWritten = $true Write-Host "Report written to $ReportPath" } +if ($integrityFailureCount -gt 0 -or $enforceableCount -eq 0) { + throw "Performance evidence integrity failed: INVALID=$integrityFailureCount, ENFORCEABLE=$enforceableCount. Report mode suppresses timing regressions only." +} if ($failureCount -gt 0 -and -not $NoFailOnRegression.IsPresent) { throw "Performance guardrail violations detected: $failureCount" diff --git a/tests/CSharpDB.Benchmarks/scripts/Compare-PairedPerfGuardrails.ps1 b/tests/CSharpDB.Benchmarks/scripts/Compare-PairedPerfGuardrails.ps1 new file mode 100644 index 00000000..df217bb6 --- /dev/null +++ b/tests/CSharpDB.Benchmarks/scripts/Compare-PairedPerfGuardrails.ps1 @@ -0,0 +1,82 @@ +#requires -Version 7.0 +[CmdletBinding()] +param([Parameter(Mandatory)][string]$OutputPath, [switch]$NoFailOnRegression) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +. (Join-Path $scriptDir 'Perf-Evidence.ps1') +$OutputPath = (Resolve-Path -LiteralPath $OutputPath).Path +$summaryPath = Join-Path $OutputPath 'report.md' +$reportWritten = $false +@('# Paired performance guardrails', '', 'Status: INCOMPLETE', '', 'Comparison has not completed.') | + Set-Content -LiteralPath $summaryPath -Encoding utf8 +# These are generated reports; never retain an earlier successful pair report +# when a recheck stops before that pair is compared. +foreach ($definition in @(Get-PerfPairSchedule)) { + $oldReport = Join-Path $OutputPath "pair-$($definition.pair)-report.md" + if (Test-Path -LiteralPath $oldReport) { Remove-Item -LiteralPath $oldReport } +} +trap { + if (-not $reportWritten) { + @('# Paired performance guardrails', '', 'Status: INVALID', '', + "Generated (UTC): $([DateTime]::UtcNow.ToString('u'))", '', $_.Exception.Message) | + Set-Content -LiteralPath $summaryPath -Encoding utf8 + } + throw $_ +} +$plan = Get-Content -LiteralPath (Join-Path $OutputPath 'paired.json') -Raw | ConvertFrom-Json +$thresholds = Join-Path $OutputPath 'thresholds.json' +if ($plan.schemaVersion -ne 1 -or $plan.baseCommit -notmatch '^[a-f0-9]{40}$' -or $plan.headCommit -notmatch '^[a-f0-9]{40}$') { throw 'Invalid paired product provenance.' } +if ((Get-FileHash -LiteralPath $thresholds -Algorithm SHA256).Hash -ine $plan.thresholdSha256) { throw 'Paired thresholds hash mismatch.' } +$artifactPaths = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) +foreach ($artifact in @($plan.artifacts)) { + if (-not $artifactPaths.Add($artifact.path)) { throw 'Duplicate paired artifact.' } + $path = Resolve-PerfArtifactPath $OutputPath $artifact.path + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Paired artifact missing: $($artifact.path)" } + if ((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -ine $artifact.sha256) { throw "Paired artifact hash mismatch: $($artifact.path)" } +} +foreach ($required in @('thresholds.json', 'execution-order.csv', 'base-build.log', 'head-build.log', 'base-build-inputs.sha256', 'head-build-inputs.sha256')) { + if (-not $artifactPaths.Contains($required)) { throw "Paired artifact absent from manifest: $required" } +} +$schedule = @(Get-PerfPairSchedule) +if (@($plan.pairs).Count -ne $schedule.Count) { throw 'Both balanced execution orders are required.' } +$expectedExecution = @($schedule | ForEach-Object { + $definition = $_ + foreach ($role in $definition.roles) { "$($definition.pair),$($definition.order),$role,$($plan."${role}Commit")" } +}) +if ((@(Get-Content -LiteralPath (Join-Path $OutputPath 'execution-order.csv')) -join "`n") -cne ($expectedExecution -join "`n")) { throw 'Recorded execution order disagrees with balanced pair plan.' } +$paths = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) +$reports = [Collections.Generic.List[string]]::new() +$errors = [Collections.Generic.List[string]]::new() +$workloadIdentity = $null +$runtimeIdentity = $null +foreach ($expected in $schedule) { + $pair = @($plan.pairs | Where-Object pair -eq $expected.pair) + if ($pair.Count -ne 1 -or $pair[0].order -cne $expected.order) { throw 'Invalid balanced pair schedule.' } + $pair = $pair[0] + $snapshots = @{} + foreach ($role in @('base', 'head')) { + if (-not $paths.Add($pair.$role)) { throw 'A snapshot cannot be reused for two paired runs.' } + $snapshots[$role] = Resolve-PerfArtifactPath $OutputPath $pair.$role + $manifest = Assert-PerfSnapshot $snapshots[$role] + if ($manifest.productCommit -cne $plan."${role}Commit" -or $manifest.harnessIdentity -cne $plan.harnessIdentity) { throw "Invalid $role paired provenance." } + $workload = @($manifest.runs | ForEach-Object { [ordered]@{ label = $_.label; arguments = @($_.arguments) } }) | ConvertTo-Json -Compress -Depth 6 + $runtime = $manifest.runtime | ConvertTo-Json -Compress + if ($null -eq $workloadIdentity) { $workloadIdentity = $workload; $runtimeIdentity = $runtime } + elseif ($workload -cne $workloadIdentity -or $runtime -cne $runtimeIdentity) { throw 'Paired runs must use identical workloads and runtime identities in both orders.' } + } + $report = Join-Path $OutputPath "pair-$($pair.pair)-report.md" + try { + & (Join-Path $scriptDir 'Compare-Baseline.ps1') -BaselineSnapshot $snapshots.base -CurrentSnapshot $snapshots.head -ThresholdsPath $thresholds -ReportPath $report -NoFailOnRegression:$NoFailOnRegression + } + catch { $errors.Add("$($pair.order): $($_.Exception.Message)") } + $reports.Add("## $($pair.order)") + if (Test-Path -LiteralPath $report) { $reports.Add((Get-Content -LiteralPath $report -Raw)) } +} +$timingPolicy = if ($NoFailOnRegression) { 'report regressions; fail invalid evidence' } else { 'fail regressions and invalid evidence' } +$summary = @('# Paired performance guardrails', '', "Base: $($plan.baseCommit)", "Head: $($plan.headCommit)", "Harness: $($plan.harnessIdentity)", '', + 'Coverage: every configured check must compare every selected row with at least one enforceable comparison in each order.', + "Threshold policy: $timingPolicy", '', @($reports), '', @($errors)) +$summary | Set-Content -LiteralPath $summaryPath -Encoding utf8 +$reportWritten = $true +if ($errors.Count -gt 0) { throw "Paired guardrails failed: $($errors -join '; ')" } diff --git a/tests/CSharpDB.Benchmarks/scripts/Perf-Evidence.ps1 b/tests/CSharpDB.Benchmarks/scripts/Perf-Evidence.ps1 new file mode 100644 index 00000000..853276b1 --- /dev/null +++ b/tests/CSharpDB.Benchmarks/scripts/Perf-Evidence.ps1 @@ -0,0 +1,220 @@ +# Shared evidence contract for fresh captures and guardrail comparisons. +Set-StrictMode -Version Latest + +function Get-PerfProperty { + param($Object, [string]$Name, $Default = $null) + if ($null -ne $Object -and $Object.PSObject.Properties.Name -contains $Name) { return $Object.$Name } + return $Default +} + +function Get-PerfHarnessRecords { + param([Parameter(Mandatory)][string]$Root) + # Use the release comparison's source/configuration identity convention. + $records = @(Get-ChildItem -LiteralPath $Root -File -Recurse -Force | + Where-Object { + $relative = [IO.Path]::GetRelativePath($Root, $_.FullName) + $relative -notmatch '(^|[\\/])(BenchmarkDotNet.Artifacts|artifacts|baselines|comparison-snapshots|bin|obj|results|run-logs)([\\/]|$)' -and + $_.Extension -in @('.cs', '.csproj', '.json', '.props', '.targets', '.slnx', '.ps1') + } | ForEach-Object { + if ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "Harness file is a reparse point: $($_.FullName)" } + [pscustomobject]@{ + path = [IO.Path]::GetRelativePath($Root, $_.FullName).Replace('\', '/') + sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + } + } | Sort-Object -CaseSensitive path) + foreach ($required in @('Program.cs', 'CSharpDB.Benchmarks.csproj')) { + if ($required -cnotin @($records.path)) { throw "Harness is missing $required." } + } + return $records +} + +function Get-PerfHarnessIdentity { + param([Parameter(Mandatory)][object[]]$Records) + $payload = (@($Records | ForEach-Object { "$($_.sha256) *$($_.path)" }) -join "`n") + "`n" + return [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($payload))).ToLowerInvariant() +} + +function Get-PerfMachineFingerprint { + $cpu = [string]$env:PROCESSOR_IDENTIFIER + if ($IsWindows) { $cpu = [string](Get-CimInstance Win32_Processor | Select-Object -First 1 -ExpandProperty Name) } + elseif ($IsLinux) { + $line = Get-Content /proc/cpuinfo | Where-Object { $_ -match '^model name\s*:' } | Select-Object -First 1 + $cpu = ($line -split ':', 2)[1].Trim() + } + elseif ($IsMacOS) { $cpu = [string](& sysctl -n machdep.cpu.brand_string) } + $sdk = [string](& dotnet --version) + if ($LASTEXITCODE -ne 0) { throw 'Could not identify the .NET SDK.' } + return [pscustomobject]@{ + fingerprintVersion = 1 + capturedUtc = [DateTime]::UtcNow.ToString('o') + runnerId = [string]$env:CSHARPDB_PERF_RUNNER_ID + machineName = [Environment]::MachineName + cpuName = $cpu.Trim() + logicalCoreCount = [Environment]::ProcessorCount + osPlatform = $(if ($IsWindows) { 'Windows' } elseif ($IsLinux) { 'Linux' } else { 'macOS' }) + osDescription = [Runtime.InteropServices.RuntimeInformation]::OSDescription.Trim() + osArchitecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + processArchitecture = [Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString() + dotnetVersion = $sdk.Trim() + } +} + +function Assert-PerfMachineFingerprint { + param([Parameter(Mandatory)]$Fingerprint) + foreach ($name in @('machineName', 'cpuName', 'osPlatform', 'osDescription', 'osArchitecture', 'processArchitecture', 'dotnetVersion')) { + if ([string]::IsNullOrWhiteSpace([string](Get-PerfProperty $Fingerprint $name))) { throw "Machine fingerprint is missing $name." } + } + if ([int](Get-PerfProperty $Fingerprint 'logicalCoreCount' 0) -le 0) { throw 'Machine fingerprint has no positive logicalCoreCount.' } +} + +function Get-PerfRuntimeIdentity { + param([string]$Project, [string]$Configuration) + Push-Location (Split-Path -Parent $Project) + try { + $json = @(& dotnet run -c $Configuration --no-build --project $Project -- --perf-environment) + if ($LASTEXITCODE -ne 0) { throw 'Could not capture benchmark runtime identity.' } + } + finally { Pop-Location } + $runtime = ($json -join "`n") | ConvertFrom-Json + Assert-PerfRuntimeIdentity $runtime + return $runtime +} + +function Assert-PerfRuntimeIdentity { + param([Parameter(Mandatory)]$Runtime) + foreach ($name in @('frameworkDescription', 'runtimeVersion', 'processArchitecture', 'targetFramework')) { + if ([string]::IsNullOrWhiteSpace([string](Get-PerfProperty $Runtime $name))) { throw "Runtime identity is missing $name." } + } +} + +function Resolve-PerfArtifactPath { + param([string]$Root, [string]$Relative) + $prefix = [IO.Path]::GetFullPath($Root).TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar + $path = [IO.Path]::GetFullPath((Join-Path $Root $Relative)) + if ([IO.Path]::IsPathRooted($Relative) -or -not $path.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "Artifact path escapes snapshot: $Relative" + } + return $path +} + +function Get-PerfExecutedMicroCases { + param([string]$LogPath) + $matches = @(Select-String -LiteralPath $LogPath -Pattern 'Global total time:.*executed benchmarks:\s*([1-9]\d*)\s*$') + if ($matches.Count -ne 1) { throw "Micro benchmark did not execute any benchmark cases: $LogPath" } + return [int]$matches[0].Matches[0].Groups[1].Value +} + +function Write-PerfSnapshotManifest { + param([string]$SnapshotDir, [string]$ProductCommit, [string]$HarnessRoot, $Runtime, [object[]]$Runs) + if ($ProductCommit -notmatch '^[a-f0-9]{40}$') { throw 'Snapshot requires a full product commit SHA.' } + Assert-PerfRuntimeIdentity $Runtime + Push-Location $HarnessRoot + try { $machine = Get-PerfMachineFingerprint } + finally { Pop-Location } + Assert-PerfMachineFingerprint $machine + $records = @(Get-PerfHarnessRecords $HarnessRoot) + $records | ConvertTo-Json -Depth 5 -AsArray | Set-Content -LiteralPath (Join-Path $SnapshotDir 'harness-files.json') -Encoding utf8 + $machine | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $SnapshotDir 'machine.json') -Encoding utf8 + $Runtime | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $SnapshotDir 'runtime.json') -Encoding utf8 + $artifacts = @(Get-ChildItem -LiteralPath $SnapshotDir -File -Recurse | Where-Object Name -ne 'manifest.json' | ForEach-Object { + [pscustomobject]@{ + path = [IO.Path]::GetRelativePath($SnapshotDir, $_.FullName).Replace('\', '/') + sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + } + } | Sort-Object -CaseSensitive path) + [ordered]@{ + schemaVersion = 1 + productCommit = $ProductCommit + harnessIdentity = Get-PerfHarnessIdentity $records + capturedUtc = [DateTime]::UtcNow.ToString('o') + machine = $machine + runtime = $Runtime + runs = @($Runs) + artifacts = $artifacts + } | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath (Join-Path $SnapshotDir 'manifest.json') -Encoding utf8 + Assert-PerfSnapshot $SnapshotDir | Out-Null +} + +function Assert-PerfSnapshot { + param([Parameter(Mandatory)][string]$SnapshotDir) + $manifestPath = Join-Path $SnapshotDir 'manifest.json' + if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { throw "Snapshot manifest missing: $manifestPath. Historical provenance must not be invented." } + $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + if ($manifest.schemaVersion -ne 1 -or $manifest.productCommit -notmatch '^[a-f0-9]{40}$' -or $manifest.harnessIdentity -notmatch '^[a-f0-9]{64}$') { + throw "Snapshot has invalid commit or harness provenance: $SnapshotDir" + } + Assert-PerfMachineFingerprint $manifest.machine + Assert-PerfRuntimeIdentity $manifest.runtime + $paths = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($artifact in @($manifest.artifacts)) { + if (-not $paths.Add($artifact.path)) { throw "Duplicate manifest artifact: $($artifact.path)" } + $path = Resolve-PerfArtifactPath $SnapshotDir $artifact.path + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Snapshot artifact missing: $path" } + if ($artifact.sha256 -notmatch '^[a-f0-9]{64}$' -or (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -ine $artifact.sha256) { + throw "Snapshot artifact hash mismatch: $path" + } + } + foreach ($required in @('machine.json', 'runtime.json', 'harness-files.json')) { + if (-not $paths.Contains($required)) { throw "Snapshot manifest does not reference $required." } + } + foreach ($name in @('machine', 'runtime')) { + $data = Get-Content -LiteralPath (Join-Path $SnapshotDir "$name.json") -Raw | ConvertFrom-Json + if (($data | ConvertTo-Json -Compress -Depth 5) -cne ($manifest.$name | ConvertTo-Json -Compress -Depth 5)) { + throw "Snapshot $name identity disagrees with manifest." + } + } + $harnessRecords = @(Get-Content -LiteralPath (Join-Path $SnapshotDir 'harness-files.json') -Raw | ConvertFrom-Json) + foreach ($required in @('Program.cs', 'CSharpDB.Benchmarks.csproj')) { + if ($required -cnotin @($harnessRecords.path)) { throw "Snapshot harness manifest is missing $required." } + } + foreach ($record in $harnessRecords) { + if ($record.sha256 -notmatch '^[a-f0-9]{64}$') { throw 'Snapshot harness contains an invalid source hash.' } + } + if ((Get-PerfHarnessIdentity $harnessRecords) -cne $manifest.harnessIdentity) { throw 'Snapshot harness identity disagrees with file manifest.' } + $csvs = @(Get-ChildItem -LiteralPath $SnapshotDir -File -Recurse -Filter '*.csv') + if ($csvs.Count -eq 0) { throw 'Snapshot contains no CSV artifacts.' } + foreach ($csv in $csvs) { + if (-not $paths.Contains([IO.Path]::GetRelativePath($SnapshotDir, $csv.FullName).Replace('\', '/'))) { throw "CSV is absent from snapshot manifest: $($csv.Name)" } + if (@(Import-Csv -LiteralPath $csv.FullName).Count -eq 0) { throw "Snapshot CSV has zero rows: $($csv.Name)" } + } + if (@($manifest.runs).Count -eq 0) { throw 'Snapshot has zero executed cases (no runs).' } + $microCsvPaths = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($run in @($manifest.runs)) { + if ([int]$run.executedCases -le 0) { throw "Snapshot run has zero executed cases: $($run.label)" } + if (-not $paths.Contains($run.logPath)) { throw "Snapshot run log is absent from manifest: $($run.logPath)" } + if (@($run.arguments) -contains '--micro') { + if ((Get-PerfExecutedMicroCases (Resolve-PerfArtifactPath $SnapshotDir $run.logPath)) -ne [int]$run.executedCases) { throw 'Snapshot executed-case count disagrees with micro log.' } + $runCsvs = @(Get-PerfProperty $run 'csvPaths' @()) + if ($runCsvs.Count -eq 0) { + # A capture with one micro invocation can unambiguously map its + # full micro-results directory to that invocation. + if (@($manifest.runs | Where-Object { @($_.arguments) -contains '--micro' }).Count -ne 1) { throw 'Snapshot is missing per-run CSV coverage.' } + $runCsvs = @($csvs | Where-Object { [IO.Path]::GetRelativePath($SnapshotDir, $_.FullName) -match '^micro-results[\\/]' } | ForEach-Object { [IO.Path]::GetRelativePath($SnapshotDir, $_.FullName).Replace('\', '/') }) + } + $exportedCases = 0 + $runCsvPaths = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($csv in $runCsvs) { + if (-not $runCsvPaths.Add($csv)) { throw "Duplicate run CSV coverage: $csv" } + if (-not $microCsvPaths.Add($csv)) { throw "Micro CSV cannot cover two benchmark invocations: $csv" } + if (-not $paths.Contains($csv)) { throw "Run CSV is absent from manifest: $csv" } + $exportedCases += @(Import-Csv -LiteralPath (Resolve-PerfArtifactPath $SnapshotDir $csv)).Count + } + if ($exportedCases -ne [int]$run.executedCases) { throw "Snapshot micro case coverage mismatch: executed=$($run.executedCases), exported=$exportedCases." } + } + } + return $manifest +} + +function Assert-PerfSnapshotCsv { + param($Manifest, [string]$SnapshotDir, [string]$CsvPath) + $relative = [IO.Path]::GetRelativePath($SnapshotDir, $CsvPath).Replace('\', '/') + if ($relative -cnotin @($Manifest.artifacts.path)) { throw "Referenced CSV is absent from snapshot manifest: $relative" } +} + +function Get-PerfPairSchedule { + # Same balanced AB / BA convention as the release comparison runner. + return @( + [pscustomobject]@{ pair = 1; order = 'base-head'; roles = @('base', 'head') }, + [pscustomobject]@{ pair = 2; order = 'head-base'; roles = @('head', 'base') } + ) +} diff --git a/tests/CSharpDB.Benchmarks/scripts/Run-PairedPerfGuardrails.ps1 b/tests/CSharpDB.Benchmarks/scripts/Run-PairedPerfGuardrails.ps1 new file mode 100644 index 00000000..b1b5d4a8 --- /dev/null +++ b/tests/CSharpDB.Benchmarks/scripts/Run-PairedPerfGuardrails.ps1 @@ -0,0 +1,95 @@ +#requires -Version 7.0 +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$BaseRoot, + [Parameter(Mandatory)][string]$HeadRoot, + [Parameter(Mandatory)][string]$OutputPath, + [ValidateSet('pr', 'release')][string]$Mode = 'release', + [string]$Configuration = 'Release', + [string]$ThresholdsPath = '', + [string[]]$MicroArguments = @(), + [switch]$NoFailOnRegression +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +. (Join-Path $scriptDir 'Perf-Evidence.ps1') +$BaseRoot = (Resolve-Path -LiteralPath $BaseRoot).Path +$HeadRoot = (Resolve-Path -LiteralPath $HeadRoot).Path +$OutputPath = [IO.Path]::GetFullPath($OutputPath) +if ($BaseRoot -eq $HeadRoot) { throw 'Paired runs require separate base and head checkouts.' } +if (Test-Path -LiteralPath $OutputPath) { throw "Paired output must be new: $OutputPath" } +New-Item -ItemType Directory -Path $OutputPath | Out-Null +$roots = @{ base = $BaseRoot; head = $HeadRoot } +$commits = @{} +foreach ($role in @('base', 'head')) { + $status = @(& git -C $roots[$role] status --porcelain --untracked-files=all) + if ($LASTEXITCODE -ne 0 -or $status.Count -gt 0) { throw "Paired $role checkout must be clean before harness synchronization." } + $commits[$role] = ([string](& git -C $roots[$role] rev-parse HEAD)).Trim() + if ($LASTEXITCODE -ne 0) { throw "Could not resolve $role product SHA." } +} +$harnessRoot = [IO.Path]::GetFullPath((Join-Path $scriptDir '..')) +$records = @(Get-PerfHarnessRecords $harnessRoot) +$identity = Get-PerfHarnessIdentity $records +if ([string]::IsNullOrWhiteSpace($ThresholdsPath)) { + $ThresholdsPath = Join-Path $harnessRoot $(if ($Mode -eq 'pr') { 'perf-thresholds-pr.json' } else { 'perf-thresholds.json' }) +} +$ThresholdsPath = (Resolve-Path -LiteralPath $ThresholdsPath).Path +Copy-Item -LiteralPath $ThresholdsPath -Destination (Join-Path $OutputPath 'thresholds.json') + +foreach ($role in @('base', 'head')) { + $target = Join-Path $roots[$role] 'tests/CSharpDB.Benchmarks' + if ([IO.Path]::GetFullPath($target) -eq $harnessRoot) { throw 'Run the paired driver from a third harness checkout; product checkouts are disposable.' } + # Synchronize the entire source/configuration closure, removing obsolete files + # only inside the explicitly supplied disposable benchmark checkout. + foreach ($old in @(Get-PerfHarnessRecords $target)) { + Remove-Item -LiteralPath (Resolve-PerfArtifactPath $target $old.path) -Force + } + foreach ($record in $records) { + $destination = Resolve-PerfArtifactPath $target $record.path + New-Item -ItemType Directory -Path (Split-Path -Parent $destination) -Force | Out-Null + Copy-Item -LiteralPath (Join-Path $harnessRoot $record.path) -Destination $destination + } + if ((Get-PerfHarnessIdentity @(Get-PerfHarnessRecords $target)) -cne $identity) { throw "$role harness synchronization failed." } + $buildInputs = @(& git -C $roots[$role] ls-files '*.props' '*.targets' 'global.json' '*NuGet.config' '*.csproj' '*packages.lock.json') + if ($LASTEXITCODE -ne 0) { throw 'Could not enumerate product build inputs.' } + @($buildInputs | ForEach-Object { + $path = Join-Path $roots[$role] $_ + "$((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant()) *$_" + }) | Set-Content -LiteralPath (Join-Path $OutputPath "$role-build-inputs.sha256") + Push-Location $roots[$role] + try { + & dotnet build (Join-Path $target 'CSharpDB.Benchmarks.csproj') -c $Configuration 2>&1 | + Tee-Object -FilePath (Join-Path $OutputPath "$role-build.log") + if ($LASTEXITCODE -ne 0) { throw "$role benchmark build failed." } + } + finally { Pop-Location } +} + +$pairs = [Collections.Generic.List[object]]::new() +foreach ($definition in @(Get-PerfPairSchedule)) { + $snapshots = @{} + foreach ($role in $definition.roles) { + $relative = "pair-$($definition.pair)/$role" + $snapshot = Join-Path $OutputPath $relative + "$($definition.pair),$($definition.order),$role,$($commits[$role])" | Add-Content -LiteralPath (Join-Path $OutputPath 'execution-order.csv') + $runner = Join-Path $roots[$role] 'tests/CSharpDB.Benchmarks/scripts/Run-Perf-Guardrails.ps1' + & $runner -Configuration $Configuration -Mode $Mode -ThresholdsPath $ThresholdsPath -SnapshotOutputPath $snapshot -CaptureOnly -NoBuild -MicroArguments $MicroArguments + $manifest = Assert-PerfSnapshot $snapshot + if ($manifest.productCommit -cne $commits[$role] -or $manifest.harnessIdentity -cne $identity) { throw 'Executed snapshot provenance disagrees with paired plan.' } + $snapshots[$role] = $relative + } + $pairs.Add([pscustomobject]@{ pair = $definition.pair; order = $definition.order; base = $snapshots.base; head = $snapshots.head }) +} +[ordered]@{ + schemaVersion = 1 + baseCommit = $commits.base + headCommit = $commits.head + harnessIdentity = $identity + thresholdSha256 = (Get-FileHash -LiteralPath (Join-Path $OutputPath 'thresholds.json') -Algorithm SHA256).Hash.ToLowerInvariant() + artifacts = @(@('thresholds.json', 'execution-order.csv', 'base-build.log', 'head-build.log', 'base-build-inputs.sha256', 'head-build-inputs.sha256') | ForEach-Object { + [ordered]@{ path = $_; sha256 = (Get-FileHash -LiteralPath (Join-Path $OutputPath $_) -Algorithm SHA256).Hash.ToLowerInvariant() } + }) + pairs = @($pairs) +} | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath (Join-Path $OutputPath 'paired.json') +& (Join-Path $scriptDir 'Compare-PairedPerfGuardrails.ps1') -OutputPath $OutputPath -NoFailOnRegression:$NoFailOnRegression diff --git a/tests/CSharpDB.Benchmarks/scripts/Run-Perf-Guardrails.ps1 b/tests/CSharpDB.Benchmarks/scripts/Run-Perf-Guardrails.ps1 index a1f55e7d..4adfc666 100644 --- a/tests/CSharpDB.Benchmarks/scripts/Run-Perf-Guardrails.ps1 +++ b/tests/CSharpDB.Benchmarks/scripts/Run-Perf-Guardrails.ps1 @@ -6,7 +6,11 @@ param( [ValidateSet("pr", "release")] [string]$Mode = "release", [switch]$NoFailOnRegression, - [switch]$SkipMicroRun + [switch]$SkipMicroRun, + [string]$SnapshotOutputPath = "", + [switch]$CaptureOnly, + [switch]$NoBuild, + [string[]]$MicroArguments = @() ) Set-StrictMode -Version Latest @@ -27,7 +31,7 @@ function Get-OptionalProperty if ($Object.PSObject.Properties.Name -contains $Name) { - return $Object.$Name + return ,$Object.$Name } return $DefaultValue @@ -337,6 +341,7 @@ function Add-SelectPlanDiagnosticsToReport } $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +. (Join-Path $scriptDir 'Perf-Evidence.ps1') $benchDir = (Resolve-Path (Join-Path $scriptDir "..")).Path $repoRoot = (Resolve-Path (Join-Path $benchDir "..\\..")).Path $benchmarkProject = Join-Path $benchDir "CSharpDB.Benchmarks.csproj" @@ -350,6 +355,26 @@ $script:SelectPlanStatsPattern = 'Select plan cache stats:\s*hits=(?\d+),\s*misses=(?\d+),\s*reclassifications=(?\d+),\s*stores=(?\d+),\s*entries=(?\d+)' $script:BenchmarkRunDiagnostics = New-Object System.Collections.Generic.List[object] $script:BenchmarkRunOrdinal = 0 +$script:EvidenceRuns = [Collections.Generic.List[object]]::new() +$productCommit = [string](& git -C $repoRoot rev-parse HEAD) +if ($LASTEXITCODE -ne 0) { throw 'Could not resolve product commit.' } +if ([string]::IsNullOrWhiteSpace($SnapshotOutputPath)) { + $SnapshotOutputPath = Join-Path $benchDir ('results/perf-snapshots/' + [Guid]::NewGuid().ToString('N')) +} +$SnapshotOutputPath = [IO.Path]::GetFullPath($SnapshotOutputPath) +if (Test-Path -LiteralPath $SnapshotOutputPath) { throw "Snapshot output must be new: $SnapshotOutputPath" } +New-Item -ItemType Directory -Path (Join-Path $SnapshotOutputPath 'logs') -Force | Out-Null +if ($SkipMicroRun) { throw 'Guardrails require fresh execution; SkipMicroRun cannot satisfy evidence integrity.' } +if (-not $NoBuild) { + Push-Location $benchDir + try { + & dotnet build $benchmarkProject -c $Configuration + if ($LASTEXITCODE -ne 0) { throw 'Benchmark build failed.' } + } + finally { Pop-Location } +} +$runtimeIdentity = Get-PerfRuntimeIdentity -Project $benchmarkProject -Configuration $Configuration +$captureStartUtc = [DateTime]::UtcNow if (Test-Path $runLogsDir) { @@ -412,14 +437,46 @@ function Invoke-BenchmarkRun $safeLabel = "benchmark-run" } $runLogPath = Join-Path $runLogsDir ("{0:D2}-{1}.log" -f $script:BenchmarkRunOrdinal, $safeLabel) + $runStart = [DateTime]::UtcNow - & dotnet run -c $Configuration --project $benchmarkProject -- @Arguments 2>&1 | - Tee-Object -FilePath $runLogPath - if ($LASTEXITCODE -ne 0) + Push-Location $benchDir + try + { + & dotnet run -c $Configuration --no-build --project $benchmarkProject -- @Arguments 2>&1 | + Tee-Object -FilePath $runLogPath + $runExitCode = $LASTEXITCODE + } + finally + { + Pop-Location + } + if ($runExitCode -ne 0) { throw "Benchmark step failed: $Label" } + if ($Arguments -contains "--micro") + { + $completedRuns = @( + Get-Content -Path $runLogPath | + Select-String -Pattern 'Global total time:.*executed benchmarks:\s*([1-9]\d*)\s*$' + ) + if ($completedRuns.Count -ne 1) + { + throw "Micro benchmark step did not execute any benchmark cases: $Label. See $runLogPath" + } + $executedCases = Get-PerfExecutedMicroCases $runLogPath + } + else { + $freshCsvs = @(Get-ChildItem -LiteralPath $benchResultsDir -File -Filter '*.csv' | Where-Object LastWriteTimeUtc -ge $runStart) + $executedCases = 0 + foreach ($csv in $freshCsvs) { $executedCases += @(Import-Csv -LiteralPath $csv.FullName).Count } + if ($executedCases -le 0) { throw "Benchmark step produced zero executed cases: $Label" } + } + $evidenceLog = 'logs/' + (Split-Path -Leaf $runLogPath) + Copy-Item -LiteralPath $runLogPath -Destination (Join-Path $SnapshotOutputPath $evidenceLog) + $script:EvidenceRuns.Add([pscustomobject]@{ label = $Label; arguments = $Arguments; executedCases = $executedCases; logPath = $evidenceLog; csvPaths = @() }) + $runStatsCount = 0 foreach ($line in (Get-Content -Path $runLogPath)) { @@ -449,7 +506,7 @@ function Invoke-BenchmarkRun if (-not $SkipMicroRun) { - if (@($microBenchmarks).Count -eq 0) + if (@($microBenchmarks).Count -eq 0 -and @($configuredSuites).Count -eq 0) { throw "No micro benchmark filters resolved from $resolvedThresholdsPath." } @@ -457,7 +514,7 @@ if (-not $SkipMicroRun) foreach ($definition in $microBenchmarks) { $runStartUtc = (Get-Date).ToUniversalTime() - Invoke-BenchmarkRun -Label "Micro ($($definition.Filter))" -Arguments @("--micro", "--filter", $definition.Filter) + Invoke-BenchmarkRun -Label "Micro ($($definition.Filter))" -Arguments (@("--micro", "--filter", $definition.Filter) + $MicroArguments) foreach ($csvName in @($definition.Csvs)) { @@ -473,6 +530,10 @@ if (-not $SkipMicroRun) $destinationPath = Join-Path $microResultsStagingDir $csvName Copy-Item -Path $artifact.FullName -Destination $destinationPath -Force + $snapshotMicroDir = Join-Path $SnapshotOutputPath 'micro-results' + New-Item -ItemType Directory -Path $snapshotMicroDir -Force | Out-Null + Copy-Item -LiteralPath $artifact.FullName -Destination (Join-Path $snapshotMicroDir $csvName) + $script:EvidenceRuns[-1].csvPaths += 'micro-results/' + $csvName Write-Host "Staged $($artifact.Name) -> $destinationPath" } } @@ -497,13 +558,31 @@ foreach ($suite in $configuredSuites) { $destinationPath = Join-Path $target.CurrentResultsDir $target.Csv Copy-Item -Path $artifact.FullName -Destination $destinationPath -Force + $check = @($thresholdConfig.checks | Where-Object csv -eq $target.Csv)[0] + $snapshotSuiteDir = Join-Path $SnapshotOutputPath ([string](Get-OptionalProperty $check 'baselineSubDir' 'macro-stress-scaling')) + New-Item -ItemType Directory -Path $snapshotSuiteDir -Force | Out-Null + Copy-Item -LiteralPath $artifact.FullName -Destination (Join-Path $snapshotSuiteDir $target.Csv) + $script:EvidenceRuns[-1].csvPaths += [IO.Path]::GetRelativePath($SnapshotOutputPath, (Join-Path $snapshotSuiteDir $target.Csv)).Replace('\', '/') Write-Host "Staged $($artifact.Name) -> $destinationPath" } } +foreach ($root in @(@{ path = (Join-Path $repoRoot 'BenchmarkDotNet.Artifacts'); label = 'bdn-root' }, @{ path = (Join-Path $benchDir 'BenchmarkDotNet.Artifacts'); label = 'bdn-benchmark' }, @{ path = $benchResultsDir; label = 'raw-suite-results' })) { + if (Test-Path -LiteralPath $root.path) { + foreach ($file in @(Get-ChildItem -LiteralPath $root.path -File -Recurse | Where-Object { $_.LastWriteTimeUtc -ge $captureStartUtc -and $_.Extension -in @('.csv', '.json', '.log', '.md', '.html') })) { + $target = Join-Path $SnapshotOutputPath ($root.label + '/' + [IO.Path]::GetRelativePath($root.path, $file.FullName)) + New-Item -ItemType Directory -Path (Split-Path -Parent $target) -Force | Out-Null + Copy-Item -LiteralPath $file.FullName -Destination $target + } + } +} +Write-PerfSnapshotManifest -SnapshotDir $SnapshotOutputPath -ProductCommit $productCommit.Trim() -HarnessRoot $benchDir -Runtime $runtimeIdentity -Runs @($script:EvidenceRuns) +if ($CaptureOnly) { return } + $compareParams = @{ ReportPath = $reportPath ThresholdsPath = $resolvedThresholdsPath + CurrentSnapshot = $SnapshotOutputPath } if ($NoFailOnRegression.IsPresent) diff --git a/tests/CSharpDB.Benchmarks/scripts/Test-PerfGuardrails.ps1 b/tests/CSharpDB.Benchmarks/scripts/Test-PerfGuardrails.ps1 new file mode 100644 index 00000000..ebf6d0d0 --- /dev/null +++ b/tests/CSharpDB.Benchmarks/scripts/Test-PerfGuardrails.ps1 @@ -0,0 +1,429 @@ +#requires -Version 7.0 +# Focused contract tests use synthetic evidence, never historical measurements. +[CmdletBinding()] +param() +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +. (Join-Path $scriptDir 'Perf-Evidence.ps1') +$testRoot = Join-Path ([IO.Path]::GetTempPath()) ('csharpdb-perf-tests-' + [Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $testRoot | Out-Null +$script:passed = 0 +$script:failed = [Collections.Generic.List[string]]::new() +$fixtureRuntime = [pscustomobject]@{ frameworkDescription = '.NET 10.0.12'; runtimeVersion = '10.0.12'; processArchitecture = 'X64'; targetFramework = '.NETCoreApp,Version=v10.0' } +$fixtureHarness = Join-Path $testRoot 'harness' +New-Item -ItemType Directory -Path $fixtureHarness | Out-Null +'// Synthetic harness fixture' | Set-Content (Join-Path $fixtureHarness 'Program.cs') +'' | Set-Content (Join-Path $fixtureHarness 'CSharpDB.Benchmarks.csproj') +$fixtureCommit = 'a' * 40 + +function New-Fixture { + $root = Join-Path $testRoot ([Guid]::NewGuid().ToString('N')) + foreach ($role in @('base', 'head')) { + $snapshot = Join-Path $root $role + New-Item -ItemType Directory -Path (Join-Path $snapshot 'micro-results') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $snapshot 'logs') -Force | Out-Null + @('Method,Mean,Allocated,ExtraInfo', 'A,100 ns,100 B,count=10', 'B,100 ns,100 B,count=10') | Set-Content (Join-Path $snapshot 'micro-results/fixture.csv') + 'Global total time: 00:00:01, executed benchmarks: 2' | Set-Content (Join-Path $snapshot 'logs/run.log') + Write-PerfSnapshotManifest -SnapshotDir $snapshot -ProductCommit $fixtureCommit -HarnessRoot $fixtureHarness -Runtime $fixtureRuntime -Runs @( + [pscustomobject]@{ label = 'fixture'; arguments = @('--micro'); executedCases = 2; logPath = 'logs/run.log' }) + } + $policy = [pscustomobject]@{ checks = @([pscustomobject]@{ csv = 'fixture.csv'; keyColumns = @('Method'); minimumEnforceableComparisons = 1 }) } + $policy | ConvertTo-Json -Depth 8 | Set-Content (Join-Path $root 'thresholds.json') + return $root +} + +function Refresh-FixtureHashes { + param([string]$Snapshot) + # Re-sign synthetic fixture inputs so parser/coverage tests reach their actual + # failure path, rather than stopping at the deliberately separate hash gate. + $path = Join-Path $Snapshot 'manifest.json' + $manifest = Get-Content $path -Raw | ConvertFrom-Json + foreach ($artifact in $manifest.artifacts) { + $artifact.sha256 = (Get-FileHash -LiteralPath (Join-Path $Snapshot $artifact.path) -Algorithm SHA256).Hash.ToLowerInvariant() + } + $manifest.machine = Get-Content (Join-Path $Snapshot 'machine.json') -Raw | ConvertFrom-Json + $manifest.runtime = Get-Content (Join-Path $Snapshot 'runtime.json') -Raw | ConvertFrom-Json + $manifest | ConvertTo-Json -Depth 12 | Set-Content $path +} + +function Invoke-FixtureComparison { + param([string]$Root, [bool]$ReportOnly = $true) + & (Join-Path $scriptDir 'Compare-Baseline.ps1') -BaselineSnapshot (Join-Path $Root 'base') -CurrentSnapshot (Join-Path $Root 'head') -ThresholdsPath (Join-Path $Root 'thresholds.json') -ReportPath (Join-Path $Root 'report.md') -NoFailOnRegression:$ReportOnly | Out-Null +} + +function Assert-Fails { + param([scriptblock]$Action, [string]$Message) + $errorText = '' + try { & $Action | Out-Null } catch { $errorText = $_.Exception.Message } + if ($errorText -notlike "*$Message*") { throw "Expected failure '$Message'; received '$errorText'." } +} + +function Test-Case { + param([string]$Name, [scriptblock]$Action) + try { & $Action; $script:passed++; Write-Host "PASS $Name" } + catch { $script:failed.Add("${Name}: $($_.Exception.Message)"); Write-Host "FAIL ${Name}: $($_.Exception.Message)" } +} + +try { + Test-Case 'Valid same-machine comparisons pass' { + $root = New-Fixture + Invoke-FixtureComparison $root + } + Test-Case 'Timing regressions report but gate fails' { + $root = New-Fixture + $path = Join-Path $root 'head/micro-results/fixture.csv' + (Get-Content $path -Raw).Replace('100 ns', '200 ns') | Set-Content $path + Refresh-FixtureHashes (Join-Path $root 'head') + Invoke-FixtureComparison $root + Assert-Fails { Invoke-FixtureComparison $root $false } 'violations detected' + } + Test-Case 'Invalid recheck replaces previous successful report' { + $root = New-Fixture + Invoke-FixtureComparison $root + Remove-Item -LiteralPath (Join-Path $root 'head/manifest.json') + Assert-Fails { Invoke-FixtureComparison $root } 'manifest missing' + $report = Get-Content -LiteralPath (Join-Path $root 'report.md') -Raw + if ($report -notmatch 'Status: INVALID' -or $report -match 'PASS=') { throw 'Invalid evidence retained a previous successful report.' } + } + Test-Case 'Duplicate CSV references cannot inflate executed case coverage' { + $root = New-Fixture + $path = Join-Path $root 'head/manifest.json' + $manifest = Get-Content $path -Raw | ConvertFrom-Json + $manifest.runs[0] | Add-Member csvPaths @('micro-results/fixture.csv', 'micro-results/fixture.csv') + $manifest.runs[0].executedCases = 4 + 'Global total time: 00:00:01, executed benchmarks: 4' | Set-Content (Join-Path $root 'head/logs/run.log') + $manifest | ConvertTo-Json -Depth 12 | Set-Content $path + Refresh-FixtureHashes (Join-Path $root 'head') + Assert-Fails { Invoke-FixtureComparison $root } 'Duplicate run CSV coverage' + } + Test-Case 'One micro export cannot supply coverage for two invocations' { + $root = New-Fixture + $path = Join-Path $root 'head/manifest.json' + $manifest = Get-Content $path -Raw | ConvertFrom-Json + $manifest.runs[0] | Add-Member csvPaths @('micro-results/fixture.csv') + $manifest.runs = @($manifest.runs[0], $manifest.runs[0]) + $manifest | ConvertTo-Json -Depth 12 | Set-Content $path + Assert-Fails { Invoke-FixtureComparison $root } 'cannot cover two benchmark invocations' + } + foreach ($missing in @('manifest.json', 'machine.json', 'runtime.json', 'harness-files.json', 'micro-results/fixture.csv', 'logs/run.log')) { + Test-Case "Missing $missing fails report mode" { + $root = New-Fixture + Remove-Item -LiteralPath (Join-Path $root "base/$missing") + Assert-Fails { Invoke-FixtureComparison $root } 'missing' + } + } + Test-Case 'Missing baseline fails report mode' { + $root = New-Fixture + Assert-Fails { + & (Join-Path $scriptDir 'Compare-Baseline.ps1') -BaselineSnapshot (Join-Path $root 'absent') -CurrentSnapshot (Join-Path $root 'head') -ThresholdsPath (Join-Path $root 'thresholds.json') -NoFailOnRegression + } 'No baseline snapshot' + } + Test-Case 'Current data without captured provenance fails report mode' { + $root = New-Fixture + Assert-Fails { + & (Join-Path $scriptDir 'Compare-Baseline.ps1') -BaselineSnapshot (Join-Path $root 'base') -CurrentMicroResultsDir (Join-Path $root 'head/micro-results') -ThresholdsPath (Join-Path $root 'thresholds.json') -NoFailOnRegression + } 'Current snapshot manifest' + } + Test-Case 'SDK changes remain enforceable with identical measured runtime' { + $root = New-Fixture + $path = Join-Path $root 'head/machine.json' + $machine = Get-Content $path -Raw | ConvertFrom-Json + $machine.dotnetVersion = '11.0.100' + $machine | ConvertTo-Json | Set-Content $path + Refresh-FixtureHashes (Join-Path $root 'head') + Invoke-FixtureComparison $root + } + foreach ($value in @('NA', '?', 'garbage', '0 ns', '1e999 ns')) { + Test-Case "Nonnumeric or zero metric '$value' fails report mode" { + $root = New-Fixture + $path = Join-Path $root 'head/micro-results/fixture.csv' + (Get-Content $path -Raw).Replace('100 ns', $value) | Set-Content $path + Refresh-FixtureHashes (Join-Path $root 'head') + Assert-Fails { Invoke-FixtureComparison $root } $(if ($value -in @('NA', '?')) { 'integrity failed' } elseif ($value -eq '0 ns') { 'Invalid numeric' } else { 'Unable to parse' }) + } + } + Test-Case 'Nonnumeric allocation fails report mode' { + $root = New-Fixture + $path = Join-Path $root 'head/micro-results/fixture.csv' + (Get-Content $path -Raw).Replace('100 B', 'NA') | Set-Content $path + Refresh-FixtureHashes (Join-Path $root 'head') + Assert-Fails { Invoke-FixtureComparison $root } 'integrity failed' + } + Test-Case 'Allocation overflow fails report mode' { + $root = New-Fixture + $path = Join-Path $root 'head/micro-results/fixture.csv' + (Get-Content $path -Raw).Replace('100 B', (('9' * 308) + ' GB')) | Set-Content $path + Refresh-FixtureHashes (Join-Path $root 'head') + Assert-Fails { Invoke-FixtureComparison $root } 'Invalid numeric allocation' + } + foreach ($role in @('base', 'head')) { + Test-Case "Missing $role row fails report mode" { + $root = New-Fixture + $path = Join-Path $root "$role/micro-results/fixture.csv" + @(Get-Content $path | Where-Object { $_ -notlike 'B,*' }) | Set-Content $path + Refresh-FixtureHashes (Join-Path $root $role) + Assert-Fails { Invoke-FixtureComparison $root } 'micro case coverage mismatch' + } + } + Test-Case 'Duplicate rows fail' { + $root = New-Fixture + $path = Join-Path $root 'head/micro-results/fixture.csv' + (Get-Content $path -Raw).Replace('B,100 ns', 'A,100 ns') | Set-Content $path + Refresh-FixtureHashes (Join-Path $root 'head') + Assert-Fails { Invoke-FixtureComparison $root } 'Duplicate row' + } + foreach ($role in @('base', 'head')) { + Test-Case "Required $role row missing with unchanged executed count fails" { + $root = New-Fixture + $path = Join-Path $root "$role/micro-results/fixture.csv" + (Get-Content $path -Raw).Replace('B,100 ns', 'C,100 ns') | Set-Content $path + Refresh-FixtureHashes (Join-Path $root $role) + $path = Join-Path $root 'thresholds.json' + $policy = Get-Content $path -Raw | ConvertFrom-Json + $policy.checks[0] | Add-Member requiredRows @([pscustomobject]@{ Method = 'B' }) + $policy | ConvertTo-Json -Depth 8 | Set-Content $path + Assert-Fails { Invoke-FixtureComparison $root } 'integrity failed' + } + } + Test-Case 'Zero rows fail' { + $root = New-Fixture + 'Method,Mean,Allocated' | Set-Content (Join-Path $root 'head/micro-results/fixture.csv') + Refresh-FixtureHashes (Join-Path $root 'head') + Assert-Fails { Invoke-FixtureComparison $root } 'zero rows' + } + Test-Case 'Zero executed cases fail' { + $root = New-Fixture + $path = Join-Path $root 'base/manifest.json' + $manifest = Get-Content $path -Raw | ConvertFrom-Json + $manifest.runs[0].executedCases = 0 + $manifest | ConvertTo-Json -Depth 12 | Set-Content $path + Assert-Fails { Invoke-FixtureComparison $root } 'zero executed cases' + } + Test-Case 'Positive declared count cannot hide zero micro execution' { + $root = New-Fixture + 'Global total time: 00:00:01, executed benchmarks: 0' | Set-Content (Join-Path $root 'base/logs/run.log') + Refresh-FixtureHashes (Join-Path $root 'base') + Assert-Fails { Invoke-FixtureComparison $root } 'did not execute any' + } + Test-Case 'Missing commit provenance fails' { + $root = New-Fixture + $path = Join-Path $root 'base/manifest.json' + $manifest = Get-Content $path -Raw | ConvertFrom-Json + $manifest.productCommit = '' + $manifest | ConvertTo-Json -Depth 12 | Set-Content $path + Assert-Fails { Invoke-FixtureComparison $root } 'invalid commit' + } + Test-Case 'Empty fingerprint fields fail' { + $root = New-Fixture + $path = Join-Path $root 'base/machine.json' + $machine = Get-Content $path -Raw | ConvertFrom-Json + $machine.cpuName = '' + $machine | ConvertTo-Json | Set-Content $path + Refresh-FixtureHashes (Join-Path $root 'base') + Assert-Fails { Invoke-FixtureComparison $root } 'fingerprint is missing cpuName' + } + Test-Case 'All-skipped results fail report mode' { + $root = New-Fixture + $path = Join-Path $root 'base/machine.json' + $machine = Get-Content $path -Raw | ConvertFrom-Json + $machine.cpuName = 'different synthetic CPU' + $machine | ConvertTo-Json | Set-Content $path + Refresh-FixtureHashes (Join-Path $root 'base') + Assert-Fails { Invoke-FixtureComparison $root } 'ENFORCEABLE=0' + } + Test-Case 'Compatible non-authoritative results cannot satisfy coverage' { + $root = New-Fixture + $path = Join-Path $root 'base/machine.json' + $machine = Get-Content $path -Raw | ConvertFrom-Json + $machine.machineName = 'different-fixture-machine' + $machine.runnerId = '' + $machine | ConvertTo-Json | Set-Content $path + Refresh-FixtureHashes (Join-Path $root 'base') + Assert-Fails { Invoke-FixtureComparison $root } 'ENFORCEABLE=0' + } + Test-Case 'Runtime mismatch fails report mode' { + $root = New-Fixture + $path = Join-Path $root 'base/runtime.json' + $runtime = Get-Content $path -Raw | ConvertFrom-Json + $runtime.runtimeVersion = '10.0.11' + $runtime | ConvertTo-Json | Set-Content $path + Refresh-FixtureHashes (Join-Path $root 'base') + Assert-Fails { Invoke-FixtureComparison $root } 'runtime identity mismatch' + } + Test-Case 'Required rows and explicit coverage cannot be empty' { + $root = New-Fixture + $path = Join-Path $root 'thresholds.json' + $policy = Get-Content $path -Raw | ConvertFrom-Json + $policy.checks[0] | Add-Member requiredRows @() + $policy | ConvertTo-Json -Depth 8 | Set-Content $path + Assert-Fails { Invoke-FixtureComparison $root } 'ENFORCEABLE=0' + } + Test-Case 'One passing check cannot conceal an unenforceable check' { + $root = New-Fixture + $other = New-Fixture + $path = Join-Path $other 'base/machine.json' + $machine = Get-Content $path -Raw | ConvertFrom-Json + $machine.cpuName = 'different synthetic CPU' + $machine | ConvertTo-Json | Set-Content $path + Refresh-FixtureHashes (Join-Path $other 'base') + $path = Join-Path $root 'thresholds.json' + [ordered]@{ baselineSnapshot = (Join-Path $root 'base'); checks = @( + [ordered]@{ csv = 'fixture.csv'; keyColumns = @('Method'); baselineSnapshot = (Join-Path $root 'base') }, + [ordered]@{ csv = 'fixture.csv'; keyColumns = @('Method'); baselineSnapshot = (Join-Path $other 'base') } + ) } | ConvertTo-Json -Depth 8 | Set-Content $path + Assert-Fails { + & (Join-Path $scriptDir 'Compare-Baseline.ps1') -CurrentSnapshot (Join-Path $root 'head') -ThresholdsPath $path -NoFailOnRegression + } 'INVALID=1, ENFORCEABLE=2' + } + Test-Case 'Checks without comparison metrics fail' { + $root = New-Fixture + $path = Join-Path $root 'thresholds.json' + $policy = Get-Content $path -Raw | ConvertFrom-Json + $policy.checks[0] | Add-Member skipMeanComparison $true + $policy.checks[0] | Add-Member skipAllocationComparison $true + $policy | ConvertTo-Json -Depth 8 | Set-Content $path + Assert-Fails { Invoke-FixtureComparison $root } 'no comparison metrics' + } + Test-Case 'ExtraInfo missing or malformed fails report mode' { + $root = New-Fixture + $path = Join-Path $root 'thresholds.json' + $policy = Get-Content $path -Raw | ConvertFrom-Json + $policy.checks[0] | Add-Member extraInfoChecks @([pscustomobject]@{ key = 'absent'; minRatio = 0.8 }) + $policy | ConvertTo-Json -Depth 8 | Set-Content $path + Assert-Fails { Invoke-FixtureComparison $root } 'ExtraInfo absent missing' + } + Test-Case 'Artifact tampering fails' { + $root = New-Fixture + 'tampered' | Add-Content (Join-Path $root 'base/logs/run.log') + Assert-Fails { Invoke-FixtureComparison $root } 'hash mismatch' + } + Test-Case 'Referenced CSV absent from manifest fails' { + $root = New-Fixture + $path = Join-Path $root 'base/manifest.json' + $manifest = Get-Content $path -Raw | ConvertFrom-Json + $manifest.artifacts = @($manifest.artifacts | Where-Object path -ne 'micro-results/fixture.csv') + $manifest | ConvertTo-Json -Depth 12 | Set-Content $path + Assert-Fails { Invoke-FixtureComparison $root } 'absent from snapshot manifest' + } + Test-Case 'Paired comparison requires both orders and four distinct snapshots' { + $root = New-Fixture + $paths = @('pair-1/base', 'pair-1/head', 'pair-2/base', 'pair-2/head') + foreach ($path in $paths) { + $source = Join-Path $root ($path.Split('/')[1]) + New-Item -ItemType Directory -Path (Split-Path -Parent (Join-Path $root $path)) -Force | Out-Null + Copy-Item -LiteralPath $source -Destination (Join-Path $root $path) -Recurse + } + $manifest = Get-Content (Join-Path $root 'base/manifest.json') -Raw | ConvertFrom-Json + $plan = [pscustomobject]@{ + schemaVersion = 1; baseCommit = $fixtureCommit; headCommit = $fixtureCommit; harnessIdentity = $manifest.harnessIdentity + thresholdSha256 = (Get-FileHash (Join-Path $root 'thresholds.json')).Hash.ToLowerInvariant() + pairs = @([pscustomobject]@{ pair = 1; order = 'base-head'; base = $paths[0]; head = $paths[1] }, [pscustomobject]@{ pair = 2; order = 'head-base'; base = $paths[2]; head = $paths[3] }) + } + @("1,base-head,base,$fixtureCommit", "1,base-head,head,$fixtureCommit", "2,head-base,head,$fixtureCommit", "2,head-base,base,$fixtureCommit") | Set-Content (Join-Path $root 'execution-order.csv') + foreach ($name in @('base-build.log', 'head-build.log', 'base-build-inputs.sha256', 'head-build-inputs.sha256')) { 'Synthetic fixture' | Set-Content (Join-Path $root $name) } + $artifacts = @(@('thresholds.json', 'execution-order.csv', 'base-build.log', 'head-build.log', 'base-build-inputs.sha256', 'head-build-inputs.sha256') | ForEach-Object { + [pscustomobject]@{ path = $_; sha256 = (Get-FileHash -LiteralPath (Join-Path $root $_) -Algorithm SHA256).Hash.ToLowerInvariant() } + }) + $plan | Add-Member artifacts $artifacts + $path = Join-Path $root 'paired.json' + $plan | ConvertTo-Json -Depth 8 | Set-Content $path + & (Join-Path $scriptDir 'Compare-PairedPerfGuardrails.ps1') -OutputPath $root -NoFailOnRegression + $snapshotManifestPath = Join-Path $root 'pair-2/head/manifest.json' + $snapshotManifest = Get-Content $snapshotManifestPath -Raw | ConvertFrom-Json + $snapshotManifest.runs[0].arguments = @('--different-workload') + $snapshotManifest | ConvertTo-Json -Depth 12 | Set-Content $snapshotManifestPath + Assert-Fails { & (Join-Path $scriptDir 'Compare-PairedPerfGuardrails.ps1') -OutputPath $root -NoFailOnRegression } 'identical workloads' + $report = Get-Content -LiteralPath (Join-Path $root 'report.md') -Raw + if ($report -notmatch 'Status: INVALID' -or $report -match 'PASS=') { throw 'Invalid paired evidence retained a successful summary.' } + if (Test-Path -LiteralPath (Join-Path $root 'pair-2-report.md')) { throw 'An unexecuted recheck retained a successful second-order report.' } + $snapshotManifest.runs[0].arguments = @('--micro') + $snapshotManifest | ConvertTo-Json -Depth 12 | Set-Content $snapshotManifestPath + $buildLog = Join-Path $root 'head-build.log' + Remove-Item -LiteralPath $buildLog + Assert-Fails { & (Join-Path $scriptDir 'Compare-PairedPerfGuardrails.ps1') -OutputPath $root -NoFailOnRegression } 'Paired artifact missing' + if ((Get-Content -LiteralPath (Join-Path $root 'report.md') -Raw) -notmatch 'Status: INVALID') { throw 'Preflight failure did not invalidate the paired summary.' } + if (Test-Path -LiteralPath (Join-Path $root 'pair-1-report.md')) { throw 'Preflight failure retained an earlier pair report.' } + 'Synthetic fixture' | Set-Content $buildLog + $plan.pairs[1].head = $paths[1] + $plan | ConvertTo-Json -Depth 8 | Set-Content $path + Assert-Fails { & (Join-Path $scriptDir 'Compare-PairedPerfGuardrails.ps1') -OutputPath $root -NoFailOnRegression } 'snapshot cannot be reused' + $plan.pairs[1].head = $paths[3] + 'Method,Mean,Allocated' | Set-Content (Join-Path $root 'pair-2/head/micro-results/fixture.csv') + Refresh-FixtureHashes (Join-Path $root 'pair-2/head') + $plan | ConvertTo-Json -Depth 8 | Set-Content $path + Assert-Fails { & (Join-Path $scriptDir 'Compare-PairedPerfGuardrails.ps1') -OutputPath $root -NoFailOnRegression } 'zero rows' + $plan.pairs = @($plan.pairs[0]) + $plan | ConvertTo-Json -Depth 8 | Set-Content $path + Assert-Fails { & (Join-Path $scriptDir 'Compare-PairedPerfGuardrails.ps1') -OutputPath $root -NoFailOnRegression } 'Both balanced execution orders' + } + Test-Case 'Real paired driver synchronizes harness, captures ABBA, and rejects zero execution' { + $root = Join-Path $testRoot 'driver' + foreach ($role in @('base', 'head')) { + $repo = Join-Path $root $role + $bench = Join-Path $repo 'tests/CSharpDB.Benchmarks' + New-Item -ItemType Directory -Path $bench -Force | Out-Null + '// obsolete synthetic harness' | Set-Content (Join-Path $bench 'Program.cs') + '' | Set-Content (Join-Path $bench 'CSharpDB.Benchmarks.csproj') + @('**/bin/', '**/obj/', '**/results/', '**/BenchmarkDotNet.Artifacts/') | Set-Content (Join-Path $repo '.gitignore') + & git -C $repo init --quiet + & git -C $repo add . + & git -C $repo -c user.name=PerfFixture -c user.email=fixture@example.invalid commit --quiet -m 'Synthetic test fixture' + if ($LASTEXITCODE -ne 0) { throw 'Could not initialize synthetic product checkout.' } + } + $csvName = 'CSharpDB.Benchmarks.Micro.PointLookupGuardrailBenchmarks-report.csv' + [ordered]@{ checks = @( + [ordered]@{ csv = $csvName; keyColumns = @('Method'); minimumEnforceableComparisons = 1 }, + [ordered]@{ csv = 'suite.csv'; keyColumns = @('Method'); suiteKey = 'fixture-suite'; suiteArgs = @('--fixture-suite'); outputPattern = 'suite-*.csv'; currentResultsDir = 'tests/CSharpDB.Benchmarks/results/suite'; baselineSubDir = 'macro-stress-scaling'; minimumEnforceableComparisons = 1 } + ) } | ConvertTo-Json -Depth 8 | Set-Content (Join-Path $root 'policy.json') + $global:PerfFixtureZero = $false + $global:PerfFixtureInvocations = [Collections.Generic.List[string]]::new() + $global:PerfFixtureBuildDirectories = [Collections.Generic.List[string]]::new() + $global:PerfFixtureRuntimeDirectories = [Collections.Generic.List[string]]::new() + $global:PerfFixtureSdkDirectories = [Collections.Generic.List[string]]::new() + function dotnet { + $global:LASTEXITCODE = 0 + if ($args -contains '--version') { $global:PerfFixtureSdkDirectories.Add($PWD.Path); return '10.0.401' } + if ($args -contains '--perf-environment') { $global:PerfFixtureRuntimeDirectories.Add($PWD.Path); return ($fixtureRuntime | ConvertTo-Json -Compress) } + if ($args[0] -eq 'build') { $global:PerfFixtureBuildDirectories.Add($PWD.Path); return 'Synthetic build accepted' } + $projectIndex = [Array]::IndexOf($args, '--project') + $bench = Split-Path -Parent $args[$projectIndex + 1] + $global:PerfFixtureInvocations.Add($bench) + if ($args -contains '--micro') { + $artifacts = Join-Path $bench 'BenchmarkDotNet.Artifacts/results' + New-Item -ItemType Directory -Path $artifacts -Force | Out-Null + @('Method,Mean,Allocated', 'A,100 ns,100 B') | Set-Content (Join-Path $artifacts $csvName) + return "Global total time: 00:00:01, executed benchmarks: $(if ($global:PerfFixtureZero) { 0 } else { 1 })" + } + $artifacts = Join-Path $bench 'bin/Release/net10.0/results' + New-Item -ItemType Directory -Path $artifacts -Force | Out-Null + @('Method,Mean,Allocated', 'A,100 ns,100 B') | Set-Content (Join-Path $artifacts ('suite-' + [Guid]::NewGuid().ToString('N') + '.csv')) + return 'Synthetic suite executed' + } + try { + & (Join-Path $scriptDir 'Run-PairedPerfGuardrails.ps1') -BaseRoot (Join-Path $root 'base') -HeadRoot (Join-Path $root 'head') -OutputPath (Join-Path $root 'evidence') -ThresholdsPath (Join-Path $root 'policy.json') -NoFailOnRegression + $roles = @($global:PerfFixtureInvocations | ForEach-Object { if ($_ -like '*driver\base\*' -or $_ -like '*driver/base/*') { 'base' } else { 'head' } }) + if (($roles -join ',') -ne 'base,base,head,head,head,head,base,base') { throw "Incorrect real driver execution order: $($roles -join ',')" } + if (($global:PerfFixtureBuildDirectories -join ',') -cne (@((Join-Path $root 'base'), (Join-Path $root 'head')) -join ',')) { throw 'Product builds did not select SDKs from their own directories.' } + $captureDirectories = @(@('base', 'head', 'head', 'base') | ForEach-Object { Join-Path $root "$_/tests/CSharpDB.Benchmarks" }) + if (($global:PerfFixtureRuntimeDirectories -join ',') -cne ($captureDirectories -join ',')) { throw 'Runtime probes did not use each product SDK context.' } + if (($global:PerfFixtureSdkDirectories -join ',') -cne ($captureDirectories -join ',')) { throw 'Fingerprint SDK capture did not use each product SDK context.' } + $plan = Get-Content (Join-Path $root 'evidence/paired.json') -Raw | ConvertFrom-Json + if ($plan.pairs.Count -ne 2) { throw 'Driver did not persist both orders.' } + $global:PerfFixtureZero = $true + $runner = Join-Path $root 'base/tests/CSharpDB.Benchmarks/scripts/Run-Perf-Guardrails.ps1' + Assert-Fails { & $runner -ThresholdsPath (Join-Path $root 'policy.json') -SnapshotOutputPath (Join-Path $root 'zero-evidence') -NoFailOnRegression -CaptureOnly -NoBuild } 'did not execute any benchmark cases' + } + finally { + Remove-Item function:dotnet + Remove-Variable PerfFixtureZero,PerfFixtureInvocations,PerfFixtureBuildDirectories,PerfFixtureRuntimeDirectories,PerfFixtureSdkDirectories -Scope Global + } + } +} +finally { + $tempPrefix = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar + if (-not [IO.Path]::GetFullPath($testRoot).StartsWith($tempPrefix, [StringComparison]::OrdinalIgnoreCase)) { throw 'Test cleanup escaped temporary directory.' } + Remove-Item -LiteralPath $testRoot -Recurse -Force +} +Write-Host "Guardrail contract tests: $script:passed passed, $($script:failed.Count) failed." +if ($script:failed.Count -gt 0) { throw ($script:failed -join "`n") } diff --git a/tests/CSharpDB.Cli.Tests/CSharpDB.Cli.Tests.csproj b/tests/CSharpDB.Cli.Tests/CSharpDB.Cli.Tests.csproj index 53cd646b..6f865cd4 100644 --- a/tests/CSharpDB.Cli.Tests/CSharpDB.Cli.Tests.csproj +++ b/tests/CSharpDB.Cli.Tests/CSharpDB.Cli.Tests.csproj @@ -23,10 +23,11 @@ - - - - + + + + + diff --git a/tests/CSharpDB.Cli.Tests/MigrationReleasePackagingTests.cs b/tests/CSharpDB.Cli.Tests/MigrationReleasePackagingTests.cs index 719a0bec..ec8d790c 100644 --- a/tests/CSharpDB.Cli.Tests/MigrationReleasePackagingTests.cs +++ b/tests/CSharpDB.Cli.Tests/MigrationReleasePackagingTests.cs @@ -168,7 +168,7 @@ public void PublishScript_UsesExpectedCombinedArchiveContract() string[] requiredLayout = [ "adapters/sqlserver/csharpdb-migration-sqlserver-worker", - "adapters/sqlserver/licenses/Microsoft.Data.SqlClient.SNI.runtime-6.0.3-LICENSE.txt", + "adapters/sqlserver/licenses/Microsoft.Data.SqlClient.SNI.runtime-7.1.0-LICENSE.txt", "adapters/mysql/csharpdb-migration-mysql-worker", "adapters/access/csharpdb-migration-access-worker", "adapters/access/CSharpDB.Migration.Access.dll", @@ -343,7 +343,7 @@ public void SqlServerWorker_PublishesPrivateReviewedSniRuntimeFailClosed() StringComparison.Ordinal)); Assert.Equal( - "6.0.3", + "7.1.0", (string?)packageReference.Attribute("Version")); Assert.Equal( "all", @@ -357,7 +357,7 @@ public void SqlServerWorker_PublishesPrivateReviewedSniRuntimeFailClosed() "scripts", "Publish-CSharpDbSqlServerMigrationBundle.ps1"); Assert.Contains( - "'Microsoft.Data.SqlClient.SNI.runtime/6.0.3'", + "'Microsoft.Data.SqlClient.SNI.runtime/7.1.0'", publisher, StringComparison.Ordinal); Assert.Contains( @@ -559,7 +559,7 @@ public void Installers_DefaultToNoOverwriteAndPreserveAdapterLayout() installer, StringComparison.Ordinal); Assert.Contains( - "adapters/sqlserver/licenses/Microsoft.Data.SqlClient.SNI.runtime-6.0.3-LICENSE.txt", + "adapters/sqlserver/licenses/Microsoft.Data.SqlClient.SNI.runtime-7.1.0-LICENSE.txt", installer, StringComparison.Ordinal); Assert.Contains( @@ -752,7 +752,7 @@ private static void CreateMinimalRelease(string source) "sql server worker", ["adapters/sqlserver/THIRD-PARTY-NOTICES.md"] = "sql server notices", - ["adapters/sqlserver/licenses/Microsoft.Data.SqlClient.SNI.runtime-6.0.3-LICENSE.txt"] = + ["adapters/sqlserver/licenses/Microsoft.Data.SqlClient.SNI.runtime-7.1.0-LICENSE.txt"] = "SNI license", [$"adapters/mysql/csharpdb-migration-mysql-worker{executableSuffix}"] = "mysql worker", diff --git a/tests/CSharpDB.CodeModules.Tests/CSharpDB.CodeModules.Tests.csproj b/tests/CSharpDB.CodeModules.Tests/CSharpDB.CodeModules.Tests.csproj index 9e9c6feb..ebaf329a 100644 --- a/tests/CSharpDB.CodeModules.Tests/CSharpDB.CodeModules.Tests.csproj +++ b/tests/CSharpDB.CodeModules.Tests/CSharpDB.CodeModules.Tests.csproj @@ -16,10 +16,11 @@ - - - - + + + + + diff --git a/tests/CSharpDB.Daemon.Tests/CSharpDB.Daemon.Tests.csproj b/tests/CSharpDB.Daemon.Tests/CSharpDB.Daemon.Tests.csproj index a4036e73..7cd88553 100644 --- a/tests/CSharpDB.Daemon.Tests/CSharpDB.Daemon.Tests.csproj +++ b/tests/CSharpDB.Daemon.Tests/CSharpDB.Daemon.Tests.csproj @@ -21,7 +21,7 @@ - + @@ -30,10 +30,11 @@ - - - - + + + + + diff --git a/tests/CSharpDB.Data.Tests/CSharpDB.Data.Tests.csproj b/tests/CSharpDB.Data.Tests/CSharpDB.Data.Tests.csproj index 3c5d0a64..699db535 100644 --- a/tests/CSharpDB.Data.Tests/CSharpDB.Data.Tests.csproj +++ b/tests/CSharpDB.Data.Tests/CSharpDB.Data.Tests.csproj @@ -20,10 +20,11 @@ - - - - + + + + + diff --git a/tests/CSharpDB.Data.Tests/PooledMemoryMappedSnapshotTests.cs b/tests/CSharpDB.Data.Tests/PooledMemoryMappedSnapshotTests.cs new file mode 100644 index 00000000..5b6fd809 --- /dev/null +++ b/tests/CSharpDB.Data.Tests/PooledMemoryMappedSnapshotTests.cs @@ -0,0 +1,132 @@ +using System.Data.Common; +using CSharpDB.Engine; +using CSharpDB.Storage.Checkpointing; +using CSharpDB.Storage.Paging; +using CSharpDB.Storage.StorageEngine; + +namespace CSharpDB.Data.Tests; + +[Collection("ConnectionPoolState")] +public sealed class PooledMemoryMappedSnapshotTests +{ + [Theory(Timeout = 60_000)] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task HeldPreparedReader_WithGrowingWriter_KeepsSnapshotAndReopensDurably( + bool mapped, bool commit) + { + CancellationToken ct = TestContext.Current.CancellationToken; + string path = Path.Combine(Path.GetTempPath(), $"pooled_mapped_snapshot_{Guid.NewGuid():N}.db"); + var options = new DatabaseOptions + { + StorageEngineOptions = new StorageEngineOptions + { + PagerOptions = new PagerOptions + { + UseMemoryMappedReads = mapped, + MaxCachedPages = 2, + CheckpointPolicy = new FrameCountCheckpointPolicy(1), + }, + }, + }; + string payload = new('x', 256); + try + { + await using (var seed = await Database.OpenAsync(path, ct)) + { + await seed.ExecuteAsync("CREATE TABLE t (id INTEGER PRIMARY KEY, value INTEGER, payload TEXT)", ct); + await seed.ExecuteAsync("INSERT INTO t VALUES " + string.Join(',', + Enumerable.Range(0, 256).Select(id => $"({id},{id},'{payload}')")), ct); + await seed.CheckpointAsync(ct); + } + long originalLength = new FileInfo(path).Length; + string connectionString = $"Data Source={path};Pooling=true;Max Pool Size=2"; + await using (var writer = new CSharpDbConnection(connectionString, options)) + await using (var connection = new CSharpDbConnection(connectionString, options)) + { + await writer.OpenAsync(ct); + await connection.OpenAsync(ct); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT id, value, payload FROM t WHERE id >= @lower AND id < @upper ORDER BY id"; + command.Parameters.AddWithValue("lower", 0L); + command.Parameters.AddWithValue("upper", 1024L); + command.Prepare(); + + await using (var held = await command.ExecuteReaderAsync(ct)) + { + // Start the cursor before the writer changes pages, so this reader + // retains a live base-file mapping and crosses multiple later leaves. + Assert.True(await held.ReadAsync(ct)); + AssertRow(held, 0, 0, payload); + string retainedText = held.GetString(2); + await using (var transaction = await writer.BeginTransactionAsync(ct)) + { + await ExecuteAsync(writer, "UPDATE t SET value = value + 1000", ct); + await ExecuteAsync(writer, "INSERT INTO t VALUES " + string.Join(',', + Enumerable.Range(256, 768).Select(id => $"({id},{id},'{payload}')")), ct); + await using (var uncommitted = await command.ExecuteReaderAsync(ct)) + await AssertRowsAsync(uncommitted, 0, 256, changed: false, payload, ct); + if (commit) await transaction.CommitAsync(ct); + else await transaction.RollbackAsync(ct); + } + await AssertRowsAsync(held, 1, 256, changed: false, payload, ct); + Assert.Equal(payload, retainedText); + await using var current = await command.ExecuteReaderAsync(ct); + await AssertRowsAsync(current, 0, commit ? 1024 : 256, commit, payload, ct); + } + + // Reuse the prepared command after releasing both snapshot readers. + command.Parameters[0].Value = 250L; + command.Parameters[1].Value = 270L; + await using var next = await command.ExecuteReaderAsync(ct); + await AssertRowsAsync(next, 250, commit ? 270 : 256, commit, payload, ct); + } + + await CSharpDbConnection.ClearAllPoolsAsync(); + await using var reopened = await Database.OpenAsync(path, options, ct); + await reopened.CheckpointAsync(ct); + Assert.Equal(commit, new FileInfo(path).Length > originalLength); + await using var result = await reopened.ExecuteAsync("SELECT id, value, payload FROM t ORDER BY id", ct); + var rows = await result.ToListAsync(ct); + Assert.Equal(commit ? 1024 : 256, rows.Count); + for (int id = 0; id < rows.Count; id++) + { + Assert.Equal(id, rows[id][0].AsInteger); + Assert.Equal(id + (commit && id < 256 ? 1000 : 0), rows[id][1].AsInteger); + Assert.Equal(payload, rows[id][2].AsText); + } + } + finally + { + await CSharpDbConnection.ClearAllPoolsAsync(); + foreach (string file in new[] { path, path + ".wal", path + ".lock" }) + if (File.Exists(file)) File.Delete(file); + } + } + + private static async Task ExecuteAsync(CSharpDbConnection connection, string sql, CancellationToken ct) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + await command.ExecuteNonQueryAsync(ct); + } + + private static async Task AssertRowsAsync(DbDataReader reader, int first, int end, bool changed, string payload, CancellationToken ct) + { + for (int id = first; id < end; id++) + { + Assert.True(await reader.ReadAsync(ct)); + AssertRow(reader, id, id + (changed && id < 256 ? 1000 : 0), payload); + } + Assert.False(await reader.ReadAsync(ct)); + } + + private static void AssertRow(DbDataReader reader, long id, long value, string payload) + { + Assert.Equal(id, reader.GetInt64(0)); + Assert.Equal(value, reader.GetInt64(1)); + Assert.Equal(payload, reader.GetString(2)); + } +} diff --git a/tests/CSharpDB.Data.Tests/PooledOrdinaryPrimaryKeyRangeTests.cs b/tests/CSharpDB.Data.Tests/PooledOrdinaryPrimaryKeyRangeTests.cs new file mode 100644 index 00000000..6f640d38 --- /dev/null +++ b/tests/CSharpDB.Data.Tests/PooledOrdinaryPrimaryKeyRangeTests.cs @@ -0,0 +1,153 @@ +using System.Data.Common; +using CSharpDB.Data; + +namespace CSharpDB.Data.Tests; + +[Collection("ConnectionPoolState")] +public sealed class PooledOrdinaryPrimaryKeyRangeTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ChangingBoundsAndOverlappingReaders_KeepOrdinaryExpressionValues(bool join) + { + string path = NewPath(); + try + { + await using var connection = await OpenAsync(path); + await SeedAsync(connection, 512); + using var command = CreateRangeCommand(connection, join); + for (int execution = 0; execution < 130; execution++) + { + int first = 100 + 4 * (execution % 65); + SetBounds(command, first, first + 4); + await using var reader = await command.ExecuteReaderAsync(Ct); + await AssertRowsAsync(reader, first, 4); + } + + SetBounds(command, 100, 104); + await using var firstReader = await command.ExecuteReaderAsync(Ct); + SetBounds(command, 200, 204); + await using var secondReader = await command.ExecuteReaderAsync(Ct); + SetBounds(command, 900, 904); + await AssertRowsAsync(secondReader, 200, 4); + await AssertRowsAsync(firstReader, 100, 4); + } + finally { await CleanupAsync(path); } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task HeldOrdinaryReader_PreservesSnapshotAcrossWriterCompletionAndLaterDdl(bool commit) + { + string path = NewPath(); + try + { + await using var writer = await OpenAsync(path); + await using var connection = await OpenAsync(path); + await SeedAsync(writer, 256); + using var command = CreateRangeCommand(connection, join: false, limit: 12); + SetBounds(command, 250, 262); + + await using (var held = await command.ExecuteReaderAsync(Ct)) + { + await using (var transaction = await writer.BeginTransactionAsync(Ct)) + { + await ExecuteAsync(writer, "UPDATE t SET value = value + 1000 WHERE id >= 250"); + await ExecuteAsync(writer, "INSERT INTO t VALUES " + string.Join(',', + Enumerable.Range(256, 768).Select(id => $"({id},{id * 10})"))); + await using (var duringWrite = await command.ExecuteReaderAsync(Ct)) + await AssertRowsAsync(duringWrite, 250, 6); + if (commit) await transaction.CommitAsync(Ct); + else await transaction.RollbackAsync(Ct); + } + + await AssertRowsAsync(held, 250, 6); + await using var current = await command.ExecuteReaderAsync(Ct); + for (int id = 250; id < (commit ? 262 : 256); id++) + { + Assert.True(await current.ReadAsync(Ct)); + Assert.Equal(id + 1, current.GetInt64(0)); + Assert.Equal((id * 10L + (commit && id < 256 ? 1000 : 0)) * 2, current.GetInt64(1)); + } + Assert.False(await current.ReadAsync(Ct)); + } + + // The pool permits persistent DDL after outstanding readers have been disposed. + await ExecuteAsync(writer, "DROP TABLE t"); + await ExecuteAsync(writer, "CREATE TABLE t (value BIGINT, id INTEGER PRIMARY KEY)"); + await ExecuteAsync(writer, "INSERT INTO t VALUES (7, 250)"); + await using var replaced = await command.ExecuteReaderAsync(Ct); + Assert.True(await replaced.ReadAsync(Ct)); + Assert.Equal(251, replaced.GetInt64(0)); + Assert.Equal(14, replaced.GetInt64(1)); + Assert.False(await replaced.ReadAsync(Ct)); + } + finally { await CleanupAsync(path); } + } + + private static CSharpDbCommand CreateRangeCommand(CSharpDbConnection connection, bool join, int limit = 4) + { + CSharpDbCommand command = connection.CreateCommand(); + command.CommandText = join + ? $"SELECT l.id + 1 AS next_id, r.value * 2 AS twice_value FROM t l JOIN t r ON l.id = r.id WHERE l.id >= @lower AND l.id < @upper ORDER BY l.id LIMIT {limit}" + : $"SELECT x.id + 1 AS next_id, x.value * 2 AS twice_value FROM t x WHERE x.id >= @lower AND x.id < @upper ORDER BY x.id LIMIT {limit}"; + command.Parameters.AddWithValue("lower", 0L); + command.Parameters.AddWithValue("upper", 0L); + command.Prepare(); + return command; + } + + private static void SetBounds(CSharpDbCommand command, long lower, long upper) + { + command.Parameters[0].Value = lower; + command.Parameters[1].Value = upper; + } + + private static async Task AssertRowsAsync(DbDataReader reader, int first, int count) + { + Assert.Equal("next_id", reader.GetName(0)); + Assert.Equal("twice_value", reader.GetName(1)); + for (int id = first; id < first + count; id++) + { + Assert.True(await reader.ReadAsync(Ct)); + Assert.Equal(id + 1, reader.GetInt64(0)); + Assert.Equal(id * 20L, reader.GetInt64(1)); + } + Assert.False(await reader.ReadAsync(Ct)); + } + + private static async Task SeedAsync(CSharpDbConnection connection, int count) + { + await ExecuteAsync(connection, "CREATE TABLE t (id INTEGER PRIMARY KEY, value BIGINT)"); + await ExecuteAsync(connection, "INSERT INTO t VALUES " + string.Join(',', + Enumerable.Range(0, count).Select(id => $"({id},{id * 10})"))); + } + + private static async Task OpenAsync(string path) + { + var connection = new CSharpDbConnection($"Data Source={path};Pooling=true;Max Pool Size=2"); + await connection.OpenAsync(Ct); + return connection; + } + + private static async Task ExecuteAsync(CSharpDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + await command.ExecuteNonQueryAsync(Ct); + } + + private static string NewPath() + => Path.Combine(Path.GetTempPath(), $"pooled_ordinary_range_{Guid.NewGuid():N}.db"); + + private static async Task CleanupAsync(string path) + { + await CSharpDbConnection.ClearAllPoolsAsync(); + foreach (string file in new[] { path, path + ".wal", path + ".lock" }) + if (File.Exists(file)) File.Delete(file); + } +} diff --git a/tests/CSharpDB.Data.Tests/PooledPreparedSnapshotRangeTests.cs b/tests/CSharpDB.Data.Tests/PooledPreparedSnapshotRangeTests.cs new file mode 100644 index 00000000..983a4e2c --- /dev/null +++ b/tests/CSharpDB.Data.Tests/PooledPreparedSnapshotRangeTests.cs @@ -0,0 +1,85 @@ +using CSharpDB.Data; +using CSharpDB.Execution; +using System.Reflection; + +namespace CSharpDB.Data.Tests; + +[Collection("ConnectionPoolState")] +public sealed class PooledPreparedSnapshotRangeTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task PreparedRange_DuringGrowingWriterTransaction_UsesCommittedRoots(bool commit) + { + CancellationToken ct = TestContext.Current.CancellationToken; + string path = Path.Combine(Path.GetTempPath(), $"pooled_snapshot_range_{Guid.NewGuid():N}.db"); + try + { + await using var writer = new CSharpDbConnection($"Data Source={path};Pooling=true;Max Pool Size=2"); + await using var reader = new CSharpDbConnection(writer.ConnectionString); + await writer.OpenAsync(ct); + await reader.OpenAsync(ct); + await ExecuteAsync(writer, "CREATE TABLE t (id INTEGER PRIMARY KEY, value INTEGER)", ct); + await ExecuteAsync(writer, "INSERT INTO t VALUES " + string.Join(',', Enumerable.Range(0, 16).Select(i => $"({i},{i * 10})")), ct); + + await using var command = reader.CreateCommand(); + command.CommandText = "SELECT id, value FROM t WHERE id >= @lower AND id < @upper ORDER BY id"; + var lower = new CSharpDbParameter("lower", 0L); + var upper = new CSharpDbParameter("upper", 1024L); + command.Parameters.Add(lower); + command.Parameters.Add(upper); + command.Prepare(); + + await using var transaction = await writer.BeginTransactionAsync(ct); + await ExecuteAsync(writer, "INSERT INTO t VALUES " + string.Join(',', Enumerable.Range(16, 1008).Select(i => $"({i},{i * 10})")), ct); + await AssertRangeAsync(command, 0, 16, ct); + lower.Value = 8L; + await AssertRangeAsync(command, 8, 8, ct); + lower.Value = 64L; + await AssertRangeAsync(command, 64, 0, ct); + await using (var count = reader.CreateCommand()) + { + count.CommandText = "SELECT COUNT(*) FROM t"; + Assert.Equal(16L, await count.ExecuteScalarAsync(ct)); + } + + if (commit) await transaction.CommitAsync(ct); + else await transaction.RollbackAsync(ct); + lower.Value = 12L; + upper.Value = 20L; + await AssertRangeAsync(command, 12, commit ? 8 : 4, ct); + } + finally + { + await CSharpDbConnection.ClearAllPoolsAsync(); + foreach (string file in new[] { path, path + ".wal", path + ".lock" }) + if (File.Exists(file)) File.Delete(file); + } + } + + private static async Task ExecuteAsync(CSharpDbConnection connection, string sql, CancellationToken ct) + { + await using var command = connection.CreateCommand(); + command.CommandText = sql; + await command.ExecuteNonQueryAsync(ct); + } + + private static async Task AssertRangeAsync(CSharpDbCommand command, int first, int count, CancellationToken ct) + { + await using var reader = await command.ExecuteReaderAsync(ct); + var result = Assert.IsType(typeof(CSharpDbDataReader) + .GetField("_queryResult", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(reader)); + object? root = typeof(QueryResult) + .GetProperty("PhysicalRootOperator", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(result); + Assert.NotNull(root); + Assert.Equal("PrimaryKeyRangeScanOperator", root.GetType().Name); + for (int i = first; i < first + count; i++) + { + Assert.True(await reader.ReadAsync(ct)); + Assert.Equal(i, reader.GetInt64(0)); + Assert.Equal(i * 10, reader.GetInt64(1)); + } + Assert.False(await reader.ReadAsync(ct)); + } +} diff --git a/tests/CSharpDB.Data.Tests/PreparedCommandObservabilityTests.cs b/tests/CSharpDB.Data.Tests/PreparedCommandObservabilityTests.cs index addae6c8..902662d7 100644 --- a/tests/CSharpDB.Data.Tests/PreparedCommandObservabilityTests.cs +++ b/tests/CSharpDB.Data.Tests/PreparedCommandObservabilityTests.cs @@ -149,6 +149,48 @@ await ExecuteNonQueryAsync( Assert.DoesNotContain(secret, serialized, StringComparison.Ordinal); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task PreparedIndexedSelect_EmitsSourceFingerprintWithoutBoundValues(bool pooling) + { + const long secret = 726103829541; + const string sourceSql = "SELECT id FROM indexed_secrets WHERE marker = @marker"; + string connectionString = $"Data Source={_databasePath};Pooling={pooling}"; + using var events = new QueryEventRecorder(); + try + { + await using var connection = new CSharpDbConnection(connectionString, CreateOptions(SqlTextCaptureMode.Normalized)); + await connection.OpenAsync(Ct); + await ExecuteNonQueryAsync(connection, "CREATE TABLE indexed_secrets (id INTEGER PRIMARY KEY, marker BIGINT)"); + await ExecuteNonQueryAsync(connection, $"INSERT INTO indexed_secrets VALUES (1, {secret})"); + await ExecuteNonQueryAsync(connection, "CREATE INDEX ix_marker ON indexed_secrets(marker)"); + using var command = connection.CreateCommand(); + command.CommandText = sourceSql; + var parameter = command.Parameters.AddWithValue("marker", secret); + command.Prepare(); + events.Clear(); + Assert.Equal(1, await command.ExecuteScalarAsync(Ct)); + parameter.Value = secret + 1; + Assert.Null(await command.ExecuteScalarAsync(Ct)); + parameter.Value = secret; + Assert.Equal(1, await command.ExecuteScalarAsync(Ct)); + var completed = events.Events(); + Assert.Equal(3, completed.Length); + foreach (var item in completed) + { + Assert.Equal(SqlQueryFingerprintProvider.Instance.CreateFingerprint(sourceSql, Ct), item.Context.QueryFingerprint); + string serialized = JsonSerializer.Serialize(item, CSharpDbObservabilityJsonContext.Default.CSharpDbQueryCompletedEvent); + Assert.DoesNotContain(secret.ToString(), serialized, StringComparison.Ordinal); + } + Assert.Empty(events.Events()); + } + finally + { + if (pooling) await CSharpDbConnection.ClearPoolAsync(connectionString); + } + } + [Fact] public async Task DisabledObservability_EmitsNoPreparedCommandEvents() { diff --git a/tests/CSharpDB.Data.Tests/PreparedPrimaryKeyRangeMutationTests.cs b/tests/CSharpDB.Data.Tests/PreparedPrimaryKeyRangeMutationTests.cs new file mode 100644 index 00000000..109d8869 --- /dev/null +++ b/tests/CSharpDB.Data.Tests/PreparedPrimaryKeyRangeMutationTests.cs @@ -0,0 +1,50 @@ +using CSharpDB.Data; + +namespace CSharpDB.Data.Tests; + +public sealed class PreparedPrimaryKeyRangeMutationTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ReusedMutation_RebindsBoundsAndPreservesRollback(bool delete) + { + await using var connection = new CSharpDbConnection("Data Source=:memory:;Pooling=false"); + await connection.OpenAsync(Ct); + using var setup = connection.CreateCommand(); + setup.CommandText = "CREATE TABLE t (id INTEGER PRIMARY KEY, value INTEGER)"; + await setup.ExecuteNonQueryAsync(Ct); + setup.CommandText = "INSERT INTO t VALUES (1, 10), (2, 20), (3, 30), (4, 40), (5, 50)"; + await setup.ExecuteNonQueryAsync(Ct); + using var mutation = connection.CreateCommand(); + mutation.CommandText = (delete ? "DELETE FROM t" : "UPDATE t SET value = value + 1") + + " WHERE id >= @lower AND id < @upper"; + var lower = mutation.Parameters.AddWithValue("lower", 1L); + var upper = mutation.Parameters.AddWithValue("upper", 4L); + mutation.Prepare(); + (object Lower, object Upper, long[] Targets)[] cases = + [ + (1L, 4L, [1, 2, 3]), (3L, 6L, [3, 4, 5]), (4L, 2L, []), + (DBNull.Value, 4L, []), (1L, DBNull.Value, []), ((short)2, 4, [2, 3]), + (1L, 2L, [1]), + ]; + foreach (var item in cases) + { + await using var transaction = await connection.BeginTransactionAsync(Ct); + lower.Value = item.Lower; + upper.Value = item.Upper; + Assert.Equal(item.Targets.Length, await mutation.ExecuteNonQueryAsync(Ct)); + using var query = connection.CreateCommand(); + query.CommandText = "SELECT id, value FROM t ORDER BY id"; + var actual = new List<(long Id, long Value)>(); + await using (var reader = await query.ExecuteReaderAsync(Ct)) + while (await reader.ReadAsync(Ct)) actual.Add((reader.GetInt64(0), reader.GetInt64(1))); + Assert.Equal(Enumerable.Range(1, 5).Select(i => (long)i).Where(i => !delete || !item.Targets.Contains(i)), + actual.Select(row => row.Id)); + Assert.All(actual, row => Assert.Equal(row.Id * 10 + (!delete && item.Targets.Contains(row.Id) ? 1 : 0), row.Value)); + await transaction.RollbackAsync(Ct); + } + } +} diff --git a/tests/CSharpDB.Data.Tests/PreparedPrimaryKeyRangeReuseTests.cs b/tests/CSharpDB.Data.Tests/PreparedPrimaryKeyRangeReuseTests.cs new file mode 100644 index 00000000..6e9c9d9f --- /dev/null +++ b/tests/CSharpDB.Data.Tests/PreparedPrimaryKeyRangeReuseTests.cs @@ -0,0 +1,129 @@ +using System.Data; +using CSharpDB.Data; + +namespace CSharpDB.Data.Tests; + +public sealed class PreparedPrimaryKeyRangeReuseTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static async Task OpenAsync() + { + var connection = new CSharpDbConnection("Data Source=:memory:;Pooling=false"); + await connection.OpenAsync(Ct); + await ExecuteAsync(connection, "CREATE TABLE t (id INTEGER PRIMARY KEY, value INTEGER)"); + await ExecuteAsync(connection, "INSERT INTO t VALUES (1, 10), (2, 20), (3, 30), (4, 40), (5, NULL)"); + return connection; + } + + private static async Task ExecuteAsync(CSharpDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + await command.ExecuteNonQueryAsync(Ct); + } + + private static async Task ReadAsync(CSharpDbCommand command) + { + await using var reader = await command.ExecuteReaderAsync(Ct); + var values = new List(); + while (await reader.ReadAsync(Ct)) values.Add(reader.GetInt64(0)); + return values.ToArray(); + } + + [Fact] + public async Task ChangingBoundTypesAndNulls_MatchesOrdinaryBinding() + { + await using var connection = await OpenAsync(); + using var prepared = connection.CreateCommand(); + prepared.CommandText = "SELECT id FROM t WHERE id >= @lower AND id < @upper ORDER BY id"; + var lower = prepared.Parameters.AddWithValue("lower", 1L); + prepared.Parameters.AddWithValue("upper", 5L); + prepared.Prepare(); + using var ordinary = connection.CreateCommand(); + ordinary.CommandText = "SELECT id FROM t WHERE id >= @lower AND id < @upper AND 1 = 1 ORDER BY id"; + var controlLower = ordinary.Parameters.AddWithValue("lower", 1L); + ordinary.Parameters.AddWithValue("upper", 5L); + + async Task CompareAsync() + { + long[]? actual = null, expected = null; + Exception? actualError = await Record.ExceptionAsync(async () => actual = await ReadAsync(prepared)); + Exception? expectedError = await Record.ExceptionAsync(async () => expected = await ReadAsync(ordinary)); + Assert.Equal(expectedError?.GetType(), actualError?.GetType()); + if (actualError is null) Assert.Equal(expected, actual); + } + + object?[] values = [1L, 3, (short)2, (uint)4, null, DBNull.Value, 2m, 2.5m, 2d, "2", true, new byte[] { 2 }, 1L]; + foreach (object? value in values) + { + lower.Value = controlLower.Value = value; + await CompareAsync(); + } + lower.Value = controlLower.Value = 1; + lower.DbType = controlLower.DbType = DbType.Boolean; + await CompareAsync(); + lower.DbType = controlLower.DbType = DbType.Int64; + Assert.Equal(new long[] { 1, 2, 3, 4 }, await ReadAsync(prepared)); + } + + [Fact] + public async Task OpenReadersRetainBounds_AndLaterSchemaChangesRefreshStarMetadata() + { + await using var connection = await OpenAsync(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT * FROM t WHERE id >= @lower AND id < @upper ORDER BY id LIMIT 2"; + var lower = command.Parameters.AddWithValue("lower", 1L); + var upper = command.Parameters.AddWithValue("upper", 5L); + command.Prepare(); + await using (var first = await command.ExecuteReaderAsync(Ct)) + { + lower.Value = 3L; upper.Value = 6L; + await using var second = await command.ExecuteReaderAsync(Ct); + lower.Value = 99L; + Assert.True(await second.ReadAsync(Ct)); Assert.Equal(3, second.GetInt64(0)); + Assert.True(await first.ReadAsync(Ct)); Assert.Equal(1, first.GetInt64(0)); + Assert.True(await second.ReadAsync(Ct)); Assert.Equal(4, second.GetInt64(0)); + Assert.True(await first.ReadAsync(Ct)); Assert.Equal(2, first.GetInt64(0)); + Assert.False(await second.ReadAsync(Ct)); Assert.False(await first.ReadAsync(Ct)); + } + await ExecuteAsync(connection, "ALTER TABLE t ADD COLUMN added TEXT"); + lower.Value = 4L; upper.Value = 6L; + await using var expanded = await command.ExecuteReaderAsync(Ct); + Assert.Equal(3, expanded.FieldCount); + Assert.Equal("added", expanded.GetName(2)); + foreach (long id in new long[] { 4, 5 }) + { + Assert.True(await expanded.ReadAsync(Ct)); + Assert.Equal(id, expanded.GetInt64(0)); + Assert.True(expanded.IsDBNull(2)); + if (id == 5) Assert.True(expanded.IsDBNull(1)); + } + Assert.False(await expanded.ReadAsync(Ct)); + } + + [Theory] + [InlineData("SMALLINT", false)] + [InlineData("INTEGER", true)] + public async Task BackedAndCompositePrimaryKeyRanges_MatchOrdinaryExecution(string keyType, bool composite) + { + await using var connection = await OpenAsync(); + await ExecuteAsync(connection, "DROP TABLE t"); + await ExecuteAsync(connection, composite + ? $"CREATE TABLE t (id {keyType}, value INTEGER, PRIMARY KEY (id, value))" + : $"CREATE TABLE t (id {keyType} PRIMARY KEY, value INTEGER)"); + await ExecuteAsync(connection, composite + ? "INSERT INTO t VALUES (11, 1), (11, 2), (22, 3)" + : "INSERT INTO t VALUES (11, 1), (22, 3)"); + using var prepared = connection.CreateCommand(); + prepared.CommandText = "SELECT id FROM t WHERE id >= @lower AND id < @upper ORDER BY id"; + var lower = prepared.Parameters.AddWithValue("lower", 10L); + var upper = prepared.Parameters.AddWithValue("upper", 30L); + prepared.Prepare(); + Assert.Equal(composite ? new long[] { 11, 11, 22 } : [11L, 22L], await ReadAsync(prepared)); + lower.Value = 20L; upper.Value = 23L; + Assert.Equal(new long[] { 22 }, await ReadAsync(prepared)); + lower.Value = 1L; upper.Value = 3L; + Assert.Empty(await ReadAsync(prepared)); + } +} diff --git a/tests/CSharpDB.Data.Tests/PreparedSelectReuseTests.cs b/tests/CSharpDB.Data.Tests/PreparedSelectReuseTests.cs new file mode 100644 index 00000000..a0a4c2c9 --- /dev/null +++ b/tests/CSharpDB.Data.Tests/PreparedSelectReuseTests.cs @@ -0,0 +1,250 @@ +using System.Data; +using CSharpDB.Data; + +namespace CSharpDB.Data.Tests; + +public sealed class PreparedSelectReuseTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static async Task OpenAsync(int offset = 0) + { + var connection = new CSharpDbConnection("Data Source=:memory:;Pooling=false"); + await connection.OpenAsync(Ct); + await ExecuteAsync(connection, "CREATE TABLE t (id INTEGER PRIMARY KEY, value INTEGER)"); + await ExecuteAsync(connection, $"INSERT INTO t VALUES (1, {1 + offset}), (2, {2 + offset}), (3, {3 + offset}), (4, {4 + offset}), (5, NULL)"); + await ExecuteAsync(connection, "CREATE INDEX ix_value ON t(value)"); + return connection; + } + + private static async Task ExecuteAsync(CSharpDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + await command.ExecuteNonQueryAsync(Ct); + } + + private static async Task ReadAsync(CSharpDbCommand command) + { + await using var reader = await command.ExecuteReaderAsync(Ct); + var values = new List(); + while (await reader.ReadAsync(Ct)) values.Add(reader.GetInt64(0)); + return values.ToArray(); + } + + [Theory] + [InlineData("value")] + [InlineData("id")] + public async Task ChangingValuesAndLogicalTypes_MatchOrdinaryBinding(string keyColumn) + { + await using var connection = await OpenAsync(); + using var prepared = connection.CreateCommand(); + prepared.CommandText = $"SELECT id FROM t WHERE {keyColumn} = @p"; + var parameter = prepared.Parameters.AddWithValue("p", 2L); + prepared.Prepare(); + using var ordinary = connection.CreateCommand(); + // The constant conjunct is deliberately outside the prepared index shape, but logically equivalent. + ordinary.CommandText = $"SELECT id FROM t WHERE {keyColumn} = @p AND 1 = 1"; + var controlParameter = ordinary.Parameters.AddWithValue("p", 2L); + object?[] values = [2L, 3, (short)4, (uint)1, DBNull.Value, null, 2m, 2.5m, 2d, "2", true, new byte[] { 2 }, 2L]; + foreach (object? value in values) + { + parameter.Value = controlParameter.Value = value; + long[]? actual = null, expected = null; + Exception? actualError = await Record.ExceptionAsync(async () => actual = await ReadAsync(prepared)); + Exception? expectedError = await Record.ExceptionAsync(async () => expected = await ReadAsync(ordinary)); + Assert.Equal(expectedError?.GetType(), actualError?.GetType()); + if (actualError is null) Assert.Equal(expected, actual); + } + parameter.Value = controlParameter.Value = 1; + parameter.DbType = controlParameter.DbType = DbType.Boolean; + Assert.Equal(await ReadAsync(ordinary), await ReadAsync(prepared)); + parameter.DbType = controlParameter.DbType = DbType.Int64; + Assert.Equal(new long[] { 1 }, await ReadAsync(prepared)); + } + + [Fact] + public async Task DefaultSyncIndexedEquality_PreservesProjectionsDuplicatesAndRowWindows() + { + await using var connection = await OpenAsync(); + await ExecuteAsync(connection, "INSERT INTO t VALUES (6, 2)"); + long[][] fixtureRows = [[1, 1], [2, 2], [3, 3], [4, 4], [6, 2]]; + (string Projection, int[] Ordinals, string[] Names, string Window, int Offset, int? Limit)[] cases = + [ + ("*", [0, 1], ["id", "value"], "", 0, null), + ("value", [1], ["value"], "", 0, null), + ("value, id, value AS repeated", [1, 0, 1], ["value", "id", "repeated"], "", 0, null), + ("id", [0], ["id"], " LIMIT 1", 0, 1), + ("id", [0], ["id"], " LIMIT 1 OFFSET 1", 1, 1), + ("id", [0], ["id"], " LIMIT 0", 0, 0), + ]; + + foreach (var query in cases) + { + using var command = connection.CreateCommand(); + command.CommandText = $"SELECT {query.Projection} FROM t WHERE value = @p{query.Window}"; + var parameter = command.Parameters.AddWithValue("p", 1L); + command.Prepare(); + foreach (long value in new long[] { 1, 1, 2, 900, 4, 2, 1 }) + { + parameter.Value = value; + await using var reader = await command.ExecuteReaderAsync(Ct); + Assert.Equal(query.Names.Length, reader.FieldCount); + for (int column = 0; column < query.Names.Length; column++) + { + Assert.Equal(query.Names[column], reader.GetName(column)); + Assert.Equal("INTEGER", reader.GetDataTypeName(column)); + } + + var actual = new List(); + while (await reader.ReadAsync(Ct)) + actual.Add(string.Join(",", Enumerable.Range(0, reader.FieldCount).Select(reader.GetInt64))); + + string[] matches = fixtureRows.Where(row => row[1] == value) + .Select(row => string.Join(",", query.Ordinals.Select(ordinal => row[ordinal]))).ToArray(); + Assert.Equal(matches.Skip(query.Offset).Take(query.Limit ?? int.MaxValue).Count(), actual.Count); + foreach (string row in actual) Assert.Contains(row, matches); + // Without ORDER BY, a row window may select either matching duplicate. + if (query.Limit is null && query.Offset == 0) + Assert.Equal(matches.Order(), actual.Order()); + } + } + } + + [Theory] + [InlineData("id")] + [InlineData("value")] + public async Task PreparedStarAfterNullableColumnAdded_ReturnsFullWidthRows(string keyColumn) + { + await using var connection = await OpenAsync(); + using var command = connection.CreateCommand(); + command.CommandText = $"SELECT * FROM t WHERE {keyColumn} = @p ORDER BY {keyColumn}"; + var parameter = command.Parameters.AddWithValue("p", 1L); + command.Prepare(); + + async Task AssertRowAsync(long value, int expectedWidth) + { + parameter.Value = value; + await using var reader = await command.ExecuteReaderAsync(Ct); + Assert.Equal(expectedWidth, reader.FieldCount); + Assert.Equal("id", reader.GetName(0)); + Assert.Equal("value", reader.GetName(1)); + if (expectedWidth == 3) + { + Assert.Equal("added", reader.GetName(2)); + Assert.Equal("INTEGER", reader.GetDataTypeName(2)); + } + + bool expectedRow = value != 99; + Assert.Equal(expectedRow, await reader.ReadAsync(Ct)); + if (expectedRow) + { + Assert.Equal(value, reader.GetInt64(0)); + Assert.Equal(value, reader.GetInt64(1)); + if (expectedWidth == 3) Assert.True(reader.IsDBNull(2)); + } + Assert.False(await reader.ReadAsync(Ct)); + } + + foreach (long value in new long[] { 1, 2, 1 }) + await AssertRowAsync(value, 2); + + // Nullable additions without defaults update metadata without rewriting existing records. + await ExecuteAsync(connection, "ALTER TABLE t ADD COLUMN added INTEGER"); + foreach (long value in new long[] { 1, 1, 3, 99, 2, 2 }) + await AssertRowAsync(value, 3); + } + + [Fact] + public async Task MissingOrRenamedParameter_IsRejectedAfterCachedExecutions() + { + await using var connection = await OpenAsync(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT id FROM t WHERE value >= @lower AND value < @upper ORDER BY value"; + command.Parameters.AddWithValue("lower", 2); + var upper = command.Parameters.AddWithValue("upper", 4); + command.Prepare(); + Assert.Equal(new long[] { 2, 3 }, await ReadAsync(command)); + upper.ParameterName = "different"; + await Assert.ThrowsAsync(() => ReadAsync(command)); + upper.ParameterName = "@UPPER"; + Assert.Equal(new long[] { 2, 3 }, await ReadAsync(command)); + command.Parameters.Remove(upper); + await Assert.ThrowsAsync(() => ReadAsync(command)); + } + + [Fact] + public async Task ReusingCommandWithOpenReaders_DoesNotChangeTheirValuesOrMetadata() + { + await using var connection = await OpenAsync(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT id AS identifier, value FROM t WHERE value >= @p ORDER BY value LIMIT 2"; + var parameter = command.Parameters.AddWithValue("p", 1); + command.Prepare(); + await using var first = await command.ExecuteReaderAsync(Ct); + parameter.Value = 3; + await using var second = await command.ExecuteReaderAsync(Ct); + parameter.Value = 99; + Assert.Equal("identifier", first.GetName(0)); + Assert.Equal("INTEGER", first.GetDataTypeName(0)); + Assert.True(await second.ReadAsync(Ct)); Assert.Equal(3, second.GetInt64(0)); + Assert.True(await first.ReadAsync(Ct)); Assert.Equal(1, first.GetInt64(0)); + Assert.True(await second.ReadAsync(Ct)); Assert.Equal(4, second.GetInt64(0)); + Assert.True(await first.ReadAsync(Ct)); Assert.Equal(2, first.GetInt64(0)); + Assert.False(await second.ReadAsync(Ct)); Assert.False(await first.ReadAsync(Ct)); + } + + [Fact] + public async Task SharedSqlTemplate_UsesEachConnectionsCatalogAndValues() + { + await using var first = await OpenAsync(); + await using var second = await OpenAsync(100); + using var a = first.CreateCommand(); + using var b = second.CreateCommand(); + a.CommandText = b.CommandText = "SELECT id FROM t WHERE value = @p"; + var av = a.Parameters.AddWithValue("p", 1); + var bv = b.Parameters.AddWithValue("p", 101); + a.Prepare(); b.Prepare(); + for (int i = 0; i < 150; i++) + { + av.Value = i % 4 + 1; + bv.Value = 100 + (i + 1) % 4 + 1; + Task at = ReadAsync(a), bt = ReadAsync(b); + Assert.Equal(new[] { (long)(i % 4 + 1) }, await at); + Assert.Equal(new[] { (long)((i + 1) % 4 + 1) }, await bt); + } + await ExecuteAsync(second, "DROP INDEX ix_value"); + av.Value = 3; bv.Value = 104; + Assert.Equal(new long[] { 3 }, await ReadAsync(a)); + Assert.Equal(new long[] { 4 }, await ReadAsync(b)); + } + + [Theory] + [InlineData("SELECT id AS value FROM t WHERE value >= @p ORDER BY value DESC LIMIT 2", 1, 4, 3)] + [InlineData("SELECT (id + 10) FROM t WHERE value >= @p ORDER BY value LIMIT 2", 2, 12, 13)] + [InlineData("SELECT id FROM t WHERE value >= @p OR value IS NULL ORDER BY id LIMIT 2", 4, 4, 5)] + public async Task UnsupportedShapes_RetainTheirSemantics(string sql, long parameter, long first, long second) + { + await using var connection = await OpenAsync(); + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.Parameters.AddWithValue("p", parameter); + command.Prepare(); + Assert.Equal(new[] { first, second }, await ReadAsync(command)); + } + + [Fact] + public async Task OrderByOutputAlias_PreservesOrdinaryOrdering() + { + await using var connection = await OpenAsync(); + await ExecuteAsync(connection, "UPDATE t SET value = 10 - id WHERE id <= 4"); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT id AS value FROM t WHERE value >= @p ORDER BY value LIMIT 3"; + command.Parameters.AddWithValue("p", 1); + command.Prepare(); + using var ordinary = connection.CreateCommand(); + ordinary.CommandText = "SELECT id AS value FROM t WHERE value >= @p AND 1 = 1 ORDER BY value LIMIT 3"; + ordinary.Parameters.AddWithValue("p", 1); + Assert.Equal(await ReadAsync(ordinary), await ReadAsync(command)); + } +} diff --git a/tests/CSharpDB.DataGeneration.Tests/CSharpDB.DataGeneration.Tests.csproj b/tests/CSharpDB.DataGeneration.Tests/CSharpDB.DataGeneration.Tests.csproj index 0dab7271..37662db3 100644 --- a/tests/CSharpDB.DataGeneration.Tests/CSharpDB.DataGeneration.Tests.csproj +++ b/tests/CSharpDB.DataGeneration.Tests/CSharpDB.DataGeneration.Tests.csproj @@ -10,10 +10,11 @@ - - - - + + + + + diff --git a/tests/CSharpDB.DataPrivacy.Tests/CSharpDB.DataPrivacy.Tests.csproj b/tests/CSharpDB.DataPrivacy.Tests/CSharpDB.DataPrivacy.Tests.csproj index 428bf4bd..58daba19 100644 --- a/tests/CSharpDB.DataPrivacy.Tests/CSharpDB.DataPrivacy.Tests.csproj +++ b/tests/CSharpDB.DataPrivacy.Tests/CSharpDB.DataPrivacy.Tests.csproj @@ -4,4 +4,11 @@ Exenet10.0enableenablefalse + + + + + + + diff --git a/tests/CSharpDB.DevOps.Tests/CSharpDB.DevOps.Tests.csproj b/tests/CSharpDB.DevOps.Tests/CSharpDB.DevOps.Tests.csproj index 3b21640f..adc777a5 100644 --- a/tests/CSharpDB.DevOps.Tests/CSharpDB.DevOps.Tests.csproj +++ b/tests/CSharpDB.DevOps.Tests/CSharpDB.DevOps.Tests.csproj @@ -17,10 +17,11 @@ - - - - + + + + + diff --git a/tests/CSharpDB.EntityFrameworkCore.Tests/CSharpDB.EntityFrameworkCore.Tests.csproj b/tests/CSharpDB.EntityFrameworkCore.Tests/CSharpDB.EntityFrameworkCore.Tests.csproj index cdfd02a6..4d5c10de 100644 --- a/tests/CSharpDB.EntityFrameworkCore.Tests/CSharpDB.EntityFrameworkCore.Tests.csproj +++ b/tests/CSharpDB.EntityFrameworkCore.Tests/CSharpDB.EntityFrameworkCore.Tests.csproj @@ -24,10 +24,11 @@ - - - - + + + + + diff --git a/tests/CSharpDB.EntityFrameworkCore.Tools.Tests/CSharpDB.EntityFrameworkCore.Tools.Tests.csproj b/tests/CSharpDB.EntityFrameworkCore.Tools.Tests/CSharpDB.EntityFrameworkCore.Tools.Tests.csproj index 47cfde04..5030949e 100644 --- a/tests/CSharpDB.EntityFrameworkCore.Tools.Tests/CSharpDB.EntityFrameworkCore.Tools.Tests.csproj +++ b/tests/CSharpDB.EntityFrameworkCore.Tools.Tests/CSharpDB.EntityFrameworkCore.Tools.Tests.csproj @@ -22,10 +22,11 @@ - - - - + + + + + diff --git a/tests/CSharpDB.Mcp.Tests/CSharpDB.Mcp.Tests.csproj b/tests/CSharpDB.Mcp.Tests/CSharpDB.Mcp.Tests.csproj index 724f5842..86cdf401 100644 --- a/tests/CSharpDB.Mcp.Tests/CSharpDB.Mcp.Tests.csproj +++ b/tests/CSharpDB.Mcp.Tests/CSharpDB.Mcp.Tests.csproj @@ -14,10 +14,11 @@ - - - - + + + + + diff --git a/tests/CSharpDB.Migration.Access.Tests/CSharpDB.Migration.Access.Tests.csproj b/tests/CSharpDB.Migration.Access.Tests/CSharpDB.Migration.Access.Tests.csproj index 283597a6..c7f7eb0e 100644 --- a/tests/CSharpDB.Migration.Access.Tests/CSharpDB.Migration.Access.Tests.csproj +++ b/tests/CSharpDB.Migration.Access.Tests/CSharpDB.Migration.Access.Tests.csproj @@ -15,10 +15,11 @@ - - - - + + + + + diff --git a/tests/CSharpDB.Migration.Compatibility.Tests/CSharpDB.Migration.Compatibility.Tests.csproj b/tests/CSharpDB.Migration.Compatibility.Tests/CSharpDB.Migration.Compatibility.Tests.csproj index 82efac15..097d4d31 100644 --- a/tests/CSharpDB.Migration.Compatibility.Tests/CSharpDB.Migration.Compatibility.Tests.csproj +++ b/tests/CSharpDB.Migration.Compatibility.Tests/CSharpDB.Migration.Compatibility.Tests.csproj @@ -15,10 +15,11 @@ - - - - + + + + + diff --git a/tests/CSharpDB.Migration.DualRun.Tests/CSharpDB.Migration.DualRun.Tests.csproj b/tests/CSharpDB.Migration.DualRun.Tests/CSharpDB.Migration.DualRun.Tests.csproj index 4377f84f..43dc4acc 100644 --- a/tests/CSharpDB.Migration.DualRun.Tests/CSharpDB.Migration.DualRun.Tests.csproj +++ b/tests/CSharpDB.Migration.DualRun.Tests/CSharpDB.Migration.DualRun.Tests.csproj @@ -17,10 +17,11 @@ - - - - + + + + + diff --git a/tests/CSharpDB.Migration.Files.Tests/CSharpDB.Migration.Files.Tests.csproj b/tests/CSharpDB.Migration.Files.Tests/CSharpDB.Migration.Files.Tests.csproj index cbd8a76c..cb836355 100644 --- a/tests/CSharpDB.Migration.Files.Tests/CSharpDB.Migration.Files.Tests.csproj +++ b/tests/CSharpDB.Migration.Files.Tests/CSharpDB.Migration.Files.Tests.csproj @@ -17,10 +17,11 @@ - - - - + + + + + diff --git a/tests/CSharpDB.Migration.LiteDb.Tests/CSharpDB.Migration.LiteDb.Tests.csproj b/tests/CSharpDB.Migration.LiteDb.Tests/CSharpDB.Migration.LiteDb.Tests.csproj index 4987adda..f377877c 100644 --- a/tests/CSharpDB.Migration.LiteDb.Tests/CSharpDB.Migration.LiteDb.Tests.csproj +++ b/tests/CSharpDB.Migration.LiteDb.Tests/CSharpDB.Migration.LiteDb.Tests.csproj @@ -15,10 +15,11 @@ - - - - + + + + + diff --git a/tests/CSharpDB.Migration.MySql.Tests/CSharpDB.Migration.MySql.Tests.csproj b/tests/CSharpDB.Migration.MySql.Tests/CSharpDB.Migration.MySql.Tests.csproj index c55bc057..aa81f750 100644 --- a/tests/CSharpDB.Migration.MySql.Tests/CSharpDB.Migration.MySql.Tests.csproj +++ b/tests/CSharpDB.Migration.MySql.Tests/CSharpDB.Migration.MySql.Tests.csproj @@ -17,10 +17,11 @@ - - - - + + + + + diff --git a/tests/CSharpDB.Migration.Retained.Tests/CSharpDB.Migration.Retained.Tests.csproj b/tests/CSharpDB.Migration.Retained.Tests/CSharpDB.Migration.Retained.Tests.csproj index 72c42433..d24493b9 100644 --- a/tests/CSharpDB.Migration.Retained.Tests/CSharpDB.Migration.Retained.Tests.csproj +++ b/tests/CSharpDB.Migration.Retained.Tests/CSharpDB.Migration.Retained.Tests.csproj @@ -14,10 +14,11 @@ - - - - + + + + + diff --git a/tests/CSharpDB.Migration.SqlServer.Tests/CSharpDB.Migration.SqlServer.Tests.csproj b/tests/CSharpDB.Migration.SqlServer.Tests/CSharpDB.Migration.SqlServer.Tests.csproj index 9948eb1d..ddf17dbc 100644 --- a/tests/CSharpDB.Migration.SqlServer.Tests/CSharpDB.Migration.SqlServer.Tests.csproj +++ b/tests/CSharpDB.Migration.SqlServer.Tests/CSharpDB.Migration.SqlServer.Tests.csproj @@ -17,10 +17,11 @@ - - - - + + + + + diff --git a/tests/CSharpDB.Migration.Sqlite.Tests/CSharpDB.Migration.Sqlite.Tests.csproj b/tests/CSharpDB.Migration.Sqlite.Tests/CSharpDB.Migration.Sqlite.Tests.csproj index de5b21d9..2f923178 100644 --- a/tests/CSharpDB.Migration.Sqlite.Tests/CSharpDB.Migration.Sqlite.Tests.csproj +++ b/tests/CSharpDB.Migration.Sqlite.Tests/CSharpDB.Migration.Sqlite.Tests.csproj @@ -14,10 +14,11 @@ - - - - + + + + + diff --git a/tests/CSharpDB.Migration.Tests/CSharpDB.Migration.Tests.csproj b/tests/CSharpDB.Migration.Tests/CSharpDB.Migration.Tests.csproj index b4fef485..6bd09025 100644 --- a/tests/CSharpDB.Migration.Tests/CSharpDB.Migration.Tests.csproj +++ b/tests/CSharpDB.Migration.Tests/CSharpDB.Migration.Tests.csproj @@ -21,10 +21,11 @@ - - - - + + + + + diff --git a/tests/CSharpDB.Observability.Tests/CSharpDB.Observability.Tests.csproj b/tests/CSharpDB.Observability.Tests/CSharpDB.Observability.Tests.csproj index 44133860..305c56df 100644 --- a/tests/CSharpDB.Observability.Tests/CSharpDB.Observability.Tests.csproj +++ b/tests/CSharpDB.Observability.Tests/CSharpDB.Observability.Tests.csproj @@ -17,10 +17,11 @@ - - - - + + + + + diff --git a/tests/CSharpDB.Pipelines.Tests/CSharpDB.Pipelines.Tests.csproj b/tests/CSharpDB.Pipelines.Tests/CSharpDB.Pipelines.Tests.csproj index ab74bb97..e13a5d3b 100644 --- a/tests/CSharpDB.Pipelines.Tests/CSharpDB.Pipelines.Tests.csproj +++ b/tests/CSharpDB.Pipelines.Tests/CSharpDB.Pipelines.Tests.csproj @@ -16,10 +16,11 @@ - - - - + + + + + diff --git a/tests/CSharpDB.Tests/AssignmentCoercionFastPathRegressionTests.cs b/tests/CSharpDB.Tests/AssignmentCoercionFastPathRegressionTests.cs new file mode 100644 index 00000000..397d2d78 --- /dev/null +++ b/tests/CSharpDB.Tests/AssignmentCoercionFastPathRegressionTests.cs @@ -0,0 +1,192 @@ +using CSharpDB.Execution; +using CSharpDB.Primitives; + +namespace CSharpDB.Tests; + +public sealed class AssignmentCoercionFastPathRegressionTests +{ + [Theory] + [InlineData(SqlTypeKind.BigInt, long.MinValue)] + [InlineData(SqlTypeKind.BigInt, long.MaxValue)] + [InlineData(SqlTypeKind.BigInt, 0L)] + [InlineData(SqlTypeKind.Integer, (long)int.MinValue)] + [InlineData(SqlTypeKind.Integer, (long)int.MaxValue)] + [InlineData(SqlTypeKind.Integer, 0L)] + public void IntegerAssignments_PreserveBoundaryValuesAndPhysicalTags( + SqlTypeKind kind, + long number) + { + DbValue input = DbValue.FromInteger(number); + DbValue actual = SqlTypeCoercion.CoerceForAssignment(input, Column(kind), "values"); + + Assert.Equal(input, actual); + Assert.Equal(DbType.Integer, actual.Type); + Assert.Equal(number, actual.AsInteger); + } + + [Fact] + public void TextAssignments_PreserveEverySuppliedCodeUnitAndStringReference() + { + // TEXT assignment does not normalize Unicode, reject embedded NULs, + // or validate UTF-16. Any such work belongs to a later boundary. + string[] inputs = [string.Empty, "plain", "e\u0301", "a\0b", new string('\ud800', 1)]; + foreach (string text in inputs) + { + DbValue input = DbValue.FromText(text); + DbValue actual = SqlTypeCoercion.CoerceForAssignment( + input, Column(SqlTypeKind.Text), "values"); + + Assert.Equal(input, actual); + Assert.Equal(DbType.Text, actual.Type); + Assert.Same(text, actual.AsText); + } + } + + [Theory] + [InlineData(-2147483649L, null)] + [InlineData(2147483648L, null)] + [InlineData(-2147483649L, "values")] + [InlineData(2147483648L, "values")] + public void OutOfRangeIntegerAssignments_PreserveWrappedRangeFailure( + long number, + string? tableName) + { + string target = tableName is null ? "column 'value'" : "column 'values.value'"; + string rangeMessage = $"{number} is outside the INTEGER range -2147483648 through 2147483647."; + CSharpDbException error = Assert.Throws(() => + SqlTypeCoercion.CoerceForAssignment( + DbValue.FromInteger(number), Column(SqlTypeKind.Integer), tableName)); + + Assert.Equal(ErrorCode.TypeMismatch, error.Code); + Assert.Equal($"Value of type Integer is not valid for {target} declared as INTEGER: {rangeMessage}", error.Message); + Assert.Equal(rangeMessage, Assert.IsType(error.InnerException).Message); + } + + [Theory] + [InlineData(SqlTypeKind.Integer)] + [InlineData(SqlTypeKind.BigInt)] + public void IntegralRealAndDecimalAssignments_StillCanonicalizeToInteger(SqlTypeKind kind) + { + ColumnDefinition column = Column(kind); + foreach (DbValue input in new[] { DbValue.FromReal(42d), DbValue.FromDecimal(42m) }) + { + DbValue actual = SqlTypeCoercion.CoerceForAssignment(input, column, "values"); + Assert.Equal(DbType.Integer, actual.Type); + Assert.Equal(42L, actual.AsInteger); + } + } + + [Fact] + public void TextAssignment_RejectsIntegerWithExistingWrappedMessage() + { + CSharpDbException error = Assert.Throws(() => + SqlTypeCoercion.CoerceForAssignment( + DbValue.FromInteger(42), Column(SqlTypeKind.Text), "values")); + + Assert.Equal(ErrorCode.TypeMismatch, error.Code); + Assert.Equal( + "Value of type Integer is not valid for column 'values.value' declared as TEXT: A character value is required.", + error.Message); + Assert.Equal( + "A character value is required.", + Assert.IsType(error.InnerException).Message); + } + + [Fact] + public void NullValue_PreservesNullButDoesNotBypassColumnArgumentValidation() + { + Assert.Equal(DbValue.Null, SqlTypeCoercion.CoerceForAssignment(DbValue.Null, Column(SqlTypeKind.Text))); + ArgumentNullException error = Assert.Throws(() => + SqlTypeCoercion.CoerceForAssignment(DbValue.Null, null!)); + Assert.Equal("column", error.ParamName); + } + + [Fact] + public void MixedRow_TypeErrorPrecedesEarlierNotNullFailureAndPreservesPrefixMutation() + { + var schema = new TableSchema + { + TableName = "mixed", + Columns = + [ + Column(SqlTypeKind.Text, "required", nullable: false), + Column(SqlTypeKind.Boolean, "normalized"), + Column(SqlTypeKind.Integer, "invalid"), + Column(SqlTypeKind.Boolean, "untouched"), + ], + }; + DbValue[] row = [DbValue.Null, DbValue.FromInteger(2), DbValue.FromText("bad"), DbValue.FromInteger(3)]; + + CSharpDbException error = Assert.Throws(() => + RowConstraintValidator.ValidateRow(schema, row)); + + Assert.Equal(ErrorCode.TypeMismatch, error.Code); + Assert.Equal( + "Value of type Text is not valid for column 'mixed.invalid' declared as INTEGER: An exact integral value is required.", + error.Message); + Assert.Equal(DbValue.Null, row[0]); + Assert.Equal(DbValue.FromInteger(1), row[1]); + Assert.Equal(DbValue.FromText("bad"), row[2]); + Assert.Equal(DbValue.FromInteger(3), row[3]); + } + + [Fact] + public void MixedRow_AllCoercionsFinishBeforeNotNullFailure() + { + var schema = new TableSchema + { + TableName = "mixed", + Columns = + [ + Column(SqlTypeKind.Text, "required", nullable: false), + Column(SqlTypeKind.Boolean, "normalized"), + Column(SqlTypeKind.Char, "padded", length: 3), + Column(SqlTypeKind.Bit, "bits", length: 3), + ], + }; + DbValue[] row = [DbValue.Null, DbValue.FromInteger(2), DbValue.FromText("x"), DbValue.FromText("1")]; + + CSharpDbException error = Assert.Throws(() => + RowConstraintValidator.ValidateRow(schema, row)); + + Assert.Equal(ErrorCode.ConstraintViolation, error.Code); + Assert.Equal("NOT NULL constraint failed: mixed.required.", error.Message); + Assert.Equal(DbValue.Null, row[0]); + Assert.Equal(DbValue.FromInteger(1), row[1]); + Assert.Equal("x ", row[2].AsText); + Assert.True(row[3].IsBitString); + Assert.Equal(3, row[3].BitLength); + Assert.Equal("100", row[3].AsBitString); + } + + [Fact] + public void LogicalBlobAndLegacyBlob_KeepTheirDifferentBitMetadataBehavior() + { + DbValue bits = DbValue.FromBitString([0x80], 1); + var legacyColumn = new ColumnDefinition { Name = "value", Type = DbType.Blob }; + + DbValue legacy = SqlTypeCoercion.CoerceForAssignment(bits, legacyColumn); + DbValue logical = SqlTypeCoercion.CoerceForAssignment(bits, Column(SqlTypeKind.Blob)); + + Assert.Equal(bits, legacy); + Assert.True(legacy.IsBitString); + Assert.False(logical.IsBitString); + Assert.Same(bits.AsBlob, logical.AsBlob); + } + + private static ColumnDefinition Column( + SqlTypeKind kind, + string name = "value", + bool nullable = true, + int? length = null) + { + SqlTypeDescriptor declaredType = SqlTypeDescriptor.Create(kind, length: length); + return new ColumnDefinition + { + Name = name, + Type = declaredType.StorageType, + DeclaredType = declaredType, + Nullable = nullable, + }; + } +} diff --git a/tests/CSharpDB.Tests/BTreeBoundedCacheMutationTests.cs b/tests/CSharpDB.Tests/BTreeBoundedCacheMutationTests.cs new file mode 100644 index 00000000..31657512 --- /dev/null +++ b/tests/CSharpDB.Tests/BTreeBoundedCacheMutationTests.cs @@ -0,0 +1,278 @@ +using System.Buffers.Binary; +using CSharpDB.Storage.BTrees; +using CSharpDB.Storage.Checkpointing; +using CSharpDB.Storage.Device; +using CSharpDB.Storage.Paging; +using CSharpDB.Storage.Wal; + +namespace CSharpDB.Tests; + +[Collection("StorageConcurrency")] +public sealed class BTreeBoundedCacheMutationTests +{ + [Theory] + [InlineData(1, false)] + [InlineData(2, false)] + [InlineData(1, true)] + [InlineData(2, true)] + public async Task InsertIntoCleanTree_LeafAndInteriorSplitsRemainOrderedAndSearchable( + int cachePages, bool mapped) + { + var ct = TestContext.Current.CancellationToken; + string path = NewTempPath(); + var expected = new SortedDictionary(); + for (int key = 0; key < 320; key += 2) + expected.Add(key, Payload(key, 3000, revision: 0)); + + try + { + await SeedAsync(path, expected, expectedHeight: 2, ct); + await using (var pager = await OpenPagerAsync(path, cachePages, mapped, createNew: false, ct)) + { + // Reopening makes the parent and existing leaves clean. Recursive + // descent and allocation can evict a held parent with these limits. + await pager.BeginTransactionAsync(ct); + var tree = new BTree(pager, pager.SchemaRootPage); + for (int key = 1; key < 320; key += 2) + { + byte[] value = Payload(key, 3000, revision: 1); + await tree.InsertAsync(key, value, ct); + expected.Add(key, value); + } + pager.SchemaRootPage = tree.RootPageId; + Assert.Equal(3, await HeightAsync(pager, tree.RootPageId, ct)); + await AssertContentsAsync(new BTree(pager, tree.RootPageId), expected, allKeyCount: 320, ct); + await pager.CommitAsync(ct); + await pager.CheckpointAsync(ct); + await AssertContentsAsync(new BTree(pager, pager.SchemaRootPage), expected, 320, ct); + } + await AssertReopenedAsync(path, cachePages, mapped, expected, allKeyCount: 320, ct); + } + finally + { + DeleteFiles(path); + } + } + + [Theory] + [InlineData(1, false)] + [InlineData(2, false)] + [InlineData(1, true)] + [InlineData(2, true)] + public async Task ReplaceWithLargerPayloadInCleanTree_SplitsPreserveEveryKeyAndPayload( + int cachePages, bool mapped) + { + var ct = TestContext.Current.CancellationToken; + string path = NewTempPath(); + var expected = new SortedDictionary(); + for (int key = 0; key < 320; key++) + expected.Add(key, Payload(key, 1000, revision: 0)); + + try + { + await SeedAsync(path, expected, expectedHeight: 2, ct); + await using (var pager = await OpenPagerAsync(path, cachePages, mapped, createNew: false, ct)) + { + await pager.BeginTransactionAsync(ct); + var tree = new BTree(pager, pager.SchemaRootPage); + // Grow alternating entries before their neighbors so replacements + // split populated leaves rather than only following an append hint. + for (int parity = 0; parity < 2; parity++) + { + for (int key = parity; key < 320; key += 2) + { + byte[] value = Payload(key, 3000, revision: 1); + Assert.True(await tree.ReplaceAsync(key, value, ct)); + expected[key] = value; + } + } + pager.SchemaRootPage = tree.RootPageId; + Assert.Equal(3, await HeightAsync(pager, tree.RootPageId, ct)); + await AssertContentsAsync(new BTree(pager, tree.RootPageId), expected, allKeyCount: 320, ct); + await pager.CommitAsync(ct); + await pager.CheckpointAsync(ct); + await AssertContentsAsync(new BTree(pager, pager.SchemaRootPage), expected, 320, ct); + } + await AssertReopenedAsync(path, cachePages, mapped, expected, allKeyCount: 320, ct); + } + finally + { + DeleteFiles(path); + } + } + + [Theory] + [InlineData(1, false)] + [InlineData(2, false)] + [InlineData(1, true)] + [InlineData(2, true)] + public async Task DeleteFromCleanTree_RebalanceAndRootCollapsePreserveRemainingRows( + int cachePages, bool mapped) + { + var ct = TestContext.Current.CancellationToken; + string path = NewTempPath(); + const int rowCount = 200; + var expected = new SortedDictionary(); + for (int key = 0; key < rowCount; key++) + expected.Add(key, Payload(key, 2000, revision: 0)); + + try + { + // Two rows fit per leaf. A two-level tree exercises borrowing and + // merging beneath a clean root, then the supported root-to-leaf + // collapse. Deeper trees may retain underfull interior pages. + await SeedAsync(path, expected, expectedHeight: 2, ct); + await using (var pager = await OpenPagerAsync(path, cachePages, mapped, createNew: false, ct)) + { + await pager.BeginTransactionAsync(ct); + var tree = new BTree(pager, pager.SchemaRootPage); + for (int low = 0; low < rowCount / 2 - 1; low++) + { + int high = rowCount - 1 - low; + Assert.True(await tree.DeleteAsync(low, ct)); + expected.Remove(low); + Assert.True(await tree.DeleteAsync(high, ct)); + expected.Remove(high); + } + // Empty the final sibling as well; this implementation permits + // one row per non-root leaf, so two survivors need not merge. + Assert.True(await tree.DeleteAsync(rowCount / 2, ct)); + expected.Remove(rowCount / 2); + pager.SchemaRootPage = tree.RootPageId; + Assert.Single(expected); + Assert.Equal(1, await HeightAsync(pager, tree.RootPageId, ct)); + Assert.NotEqual(0u, pager.FreelistHead); + await AssertContentsAsync(new BTree(pager, tree.RootPageId), expected, allKeyCount: rowCount, ct); + await pager.CommitAsync(ct); + await pager.CheckpointAsync(ct); + await AssertContentsAsync(new BTree(pager, pager.SchemaRootPage), expected, rowCount, ct); + } + await AssertReopenedAsync(path, cachePages, mapped, expected, allKeyCount: rowCount, ct); + } + finally + { + DeleteFiles(path); + } + } + + private static async Task SeedAsync(string path, SortedDictionary rows, + int expectedHeight, CancellationToken ct) + { + // The fixture does not depend on the bounded-cache mutation behavior under + // test. Its root is persisted in the pager header for independent reopening. + await using var pager = await OpenPagerAsync(path, cachePages: null, mapped: false, createNew: true, ct); + await pager.BeginTransactionAsync(ct); + var tree = new BTree(pager, await BTree.CreateNewAsync(pager, ct)); + foreach (var row in rows) + await tree.InsertAsync(row.Key, row.Value, ct); + pager.SchemaRootPage = tree.RootPageId; + Assert.Equal(expectedHeight, await HeightAsync(pager, tree.RootPageId, ct)); + await pager.CommitAsync(ct); + await pager.CheckpointAsync(ct); + } + + private static async Task AssertReopenedAsync(string path, int cachePages, bool mapped, + SortedDictionary expected, int allKeyCount, CancellationToken ct) + { + await using var pager = await OpenPagerAsync(path, cachePages, mapped, createNew: false, ct); + await AssertContentsAsync(new BTree(pager, pager.SchemaRootPage), expected, allKeyCount, ct); + } + + private static async Task AssertContentsAsync(BTree tree, SortedDictionary expected, + int allKeyCount, CancellationToken ct) + { + await using (var cursor = tree.CreateCursor()) + { + foreach (var row in expected) + { + Assert.True(await cursor.MoveNextAsync(ct), $"Missing scan row for key {row.Key}."); + Assert.Equal(row.Key, cursor.CurrentKey); + Assert.Equal(row.Value, cursor.CurrentValue.ToArray()); + } + Assert.False(await cursor.MoveNextAsync(ct)); + } + + // Every inserted key is checked independently: a leaf chain can contain + // all rows while a corrupted parent makes individual keys unreachable. + for (int key = 0; key < allKeyCount; key++) + { + byte[]? actual = await tree.FindAsync(key, ct); + if (expected.TryGetValue(key, out byte[]? value)) + { + Assert.NotNull(actual); + Assert.Equal(value, actual); + } + else + Assert.Null(actual); + } + } + + private static async ValueTask HeightAsync(Pager pager, uint pageId, CancellationToken ct) + { + for (int height = 1; height <= 8; height++) + { + byte[] page = await pager.GetPageAsync(pageId, ct); + var slotted = new SlottedPage(page, pageId); + if (slotted.PageType == PageConstants.PageTypeLeaf) + return height; + Assert.Equal(PageConstants.PageTypeInterior, slotted.PageType); + pageId = slotted.RightChildOrNextLeaf; + Assert.NotEqual(PageConstants.NullPageId, pageId); + } + throw new InvalidOperationException("Unexpected B-tree height or cyclic interior chain."); + } + + private static async ValueTask OpenPagerAsync(string path, int? cachePages, bool mapped, + bool createNew, CancellationToken ct) + { + var device = new FileStorageDevice(path, createNew); + var index = new WalIndex(); + var wal = new WriteAheadLog(path, index); + Pager? pager = null; + try + { + pager = await Pager.CreateAsync(device, wal, index, new PagerOptions + { + MaxCachedPages = cachePages, + UseMemoryMappedReads = mapped, + EnableSequentialLeafReadAhead = false, + PreserveOwnedPagesOnCheckpoint = false, + CheckpointPolicy = new FrameCountCheckpointPolicy(100_000), + }, ct); + if (createNew) + await pager.InitializeNewDatabaseAsync(ct); + await pager.RecoverAsync(ct); + return pager; + } + catch + { + if (pager is not null) + await pager.DisposeAsync(); + else + { + await wal.DisposeAsync(); + await device.DisposeAsync(); + } + throw; + } + } + + private static byte[] Payload(int key, int length, int revision) + { + byte[] result = new byte[length]; + for (int i = 0; i < result.Length; i++) + result[i] = (byte)((key * 17 + revision * 73 + i * 131) & 0xff); + BinaryPrimitives.WriteInt32LittleEndian(result, key); + BinaryPrimitives.WriteInt32LittleEndian(result.AsSpan(sizeof(int)), revision); + return result; + } + + private static string NewTempPath() => + Path.Combine(Path.GetTempPath(), $"csharpdb_btree_bounded_mutation_{Guid.NewGuid():N}.db"); + + private static void DeleteFiles(string path) + { + File.Delete(path); + File.Delete(path + ".wal"); + } +} diff --git a/tests/CSharpDB.Tests/BTreeCursorPrefetchLifecycleTests.cs b/tests/CSharpDB.Tests/BTreeCursorPrefetchLifecycleTests.cs new file mode 100644 index 00000000..6e0a0c6c --- /dev/null +++ b/tests/CSharpDB.Tests/BTreeCursorPrefetchLifecycleTests.cs @@ -0,0 +1,343 @@ +using CSharpDB.Storage.BTrees; +using CSharpDB.Storage.Checkpointing; +using CSharpDB.Storage.Device; +using CSharpDB.Storage.Indexing; +using CSharpDB.Storage.Paging; +using CSharpDB.Storage.Wal; + +namespace CSharpDB.Tests; + +[Collection("StorageConcurrency")] +public sealed class BTreeCursorPrefetchLifecycleTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ConsumedPrefetches_ContinueWithoutCancellation_AndRetainValues(bool asynchronous) + { + await using var fixture = await Fixture.CreateAsync(); + ReadControl? second = asynchronous ? fixture.Device.Hold(fixture.Leaves[1].PageId) : null; + ReadControl? third = asynchronous ? fixture.Device.Hold(fixture.Leaves[2].PageId) : null; + await using var cursor = fixture.Tree.CreateCursor(); + + Assert.True(await cursor.SeekAsync(1, Ct)); + ReadOnlyMemory firstValue = cursor.CurrentValue; + CancellationToken secondToken = Assert.Single(fixture.Device.ReadTokens(fixture.Leaves[1].PageId)); + Assert.True(secondToken.CanBeCanceled); + await MoveThroughAsync(cursor, fixture.Leaves[0].LastKey); + await CrossLeafAsync(cursor, second); + ReadOnlyMemory secondValue = cursor.CurrentValue; + Assert.Equal(fixture.Leaves[1].FirstKey, cursor.CurrentKey); + Assert.False(secondToken.IsCancellationRequested); + + CancellationToken thirdToken = Assert.Single(fixture.Device.ReadTokens(fixture.Leaves[2].PageId)); + await MoveThroughAsync(cursor, fixture.Leaves[1].LastKey); + await CrossLeafAsync(cursor, third); + ReadOnlyMemory thirdValue = cursor.CurrentValue; + Assert.Equal(fixture.Leaves[2].FirstKey, cursor.CurrentKey); + Assert.False(secondToken.IsCancellationRequested); + Assert.False(thirdToken.IsCancellationRequested); + + long expected = cursor.CurrentKey + 1; + while (await cursor.MoveNextAsync(Ct)) + { + Assert.Equal(expected++, cursor.CurrentKey); + AssertPayload(cursor.CurrentValue, cursor.CurrentKey); + } + Assert.Equal(129, expected); + Assert.False(await cursor.MoveNextAsync(Ct)); + await cursor.DisposeAsync(); + + AssertPayload(firstValue, 1); + AssertPayload(secondValue, fixture.Leaves[1].FirstKey); + AssertPayload(thirdValue, fixture.Leaves[2].FirstKey); + } + + [Fact] + public async Task CancelledWait_RetriesDemandRead_WithoutReusingPendingReadCancellation() + { + await using var fixture = await Fixture.CreateAsync(); + ReadControl abandoned = fixture.Device.Hold(fixture.Leaves[1].PageId, ignoreCancellation: true); + await using var cursor = fixture.Tree.CreateCursor(); + try + { + Assert.True(await cursor.SeekAsync(1, Ct)); + await MoveThroughAsync(cursor, fixture.Leaves[0].LastKey); + ReadOnlyMemory retained = cursor.CurrentValue; + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(Ct); + Task pending = cursor.MoveNextAsync(cancellation.Token).AsTask(); + Assert.False(pending.IsCompleted); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => pending); + + Assert.True(abandoned.Token.IsCancellationRequested); + Assert.False(abandoned.Finished.IsCompleted); + Assert.Equal(fixture.Leaves[0].LastKey, cursor.CurrentKey); + AssertPayload(retained, fixture.Leaves[0].LastKey); + Assert.True(await cursor.MoveNextAsync(Ct)); + Assert.Equal(fixture.Leaves[1].FirstKey, cursor.CurrentKey); + AssertPayload(cursor.CurrentValue, cursor.CurrentKey); + Assert.Equal(2, fixture.Device.ReadTokens(fixture.Leaves[1].PageId).Count); + Assert.False(Assert.Single(fixture.Device.ReadTokens(fixture.Leaves[2].PageId)).IsCancellationRequested); + + abandoned.Release(); + await abandoned.Finished.WaitAsync(Ct); + Assert.True(await cursor.MoveNextAsync(Ct)); + Assert.Equal(fixture.Leaves[1].FirstKey + 1, cursor.CurrentKey); + AssertPayload(retained, fixture.Leaves[0].LastKey); + } + finally + { + abandoned.Release(); + if (abandoned.Started) await abandoned.Finished.WaitAsync(Ct); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FailedSpeculativeRead_FallsBackToDemandRead_AndContinues(bool asynchronous) + { + await using var fixture = await Fixture.CreateAsync(); + ReadControl failed = fixture.Device.Hold(fixture.Leaves[1].PageId); + if (!asynchronous) failed.Fail(); + await using var cursor = fixture.Tree.CreateCursor(); + Assert.True(await cursor.SeekAsync(1, Ct)); + ReadOnlyMemory retained = cursor.CurrentValue; + await MoveThroughAsync(cursor, fixture.Leaves[0].LastKey); + Task next = cursor.MoveNextAsync(Ct).AsTask(); + if (asynchronous) + { + Assert.False(next.IsCompleted); + failed.Fail(); + } + Assert.True(await next); + Assert.Equal(fixture.Leaves[1].FirstKey, cursor.CurrentKey); + AssertPayload(cursor.CurrentValue, cursor.CurrentKey); + Assert.True(failed.Token.IsCancellationRequested); + Assert.Equal(2, fixture.Device.ReadTokens(fixture.Leaves[1].PageId).Count); + Assert.False(Assert.Single(fixture.Device.ReadTokens(fixture.Leaves[2].PageId)).IsCancellationRequested); + await MoveThroughAsync(cursor, fixture.Leaves[2].FirstKey); + AssertPayload(retained, 1); + } + + [Fact] + public async Task Reseek_AbandonsPendingRead_WithoutCancellingNewRead() + { + await using var fixture = await Fixture.CreateAsync(); + ReadControl abandoned = fixture.Device.Hold(fixture.Leaves[1].PageId, ignoreCancellation: true); + await using var cursor = fixture.Tree.CreateCursor(); + try + { + Assert.True(await cursor.SeekAsync(1, Ct)); + ReadOnlyMemory retained = cursor.CurrentValue; + Assert.True(await cursor.SeekAsync(fixture.Leaves[2].FirstKey, Ct)); + Assert.True(abandoned.Token.IsCancellationRequested); + Assert.False(abandoned.Finished.IsCompleted); + Assert.Equal(fixture.Leaves[2].FirstKey, cursor.CurrentKey); + Assert.False(Assert.Single(fixture.Device.ReadTokens(fixture.Leaves[3].PageId)).IsCancellationRequested); + + abandoned.Release(); + await abandoned.Finished.WaitAsync(Ct); + await MoveThroughAsync(cursor, fixture.Leaves[3].FirstKey); + AssertPayload(retained, 1); + } + finally + { + abandoned.Release(); + if (abandoned.Started) await abandoned.Finished.WaitAsync(Ct); + } + } + + [Fact] + public async Task Dispose_CancelsPendingRead_AndWaitsForItsCompletion() + { + await using var fixture = await Fixture.CreateAsync(); + ReadControl pendingRead = fixture.Device.Hold(fixture.Leaves[1].PageId, ignoreCancellation: true); + var cursor = fixture.Tree.CreateCursor(); + Task? disposal = null; + try + { + Assert.True(await cursor.SeekAsync(1, Ct)); + ReadOnlyMemory retained = cursor.CurrentValue; + disposal = cursor.DisposeAsync().AsTask(); + Assert.True(pendingRead.Token.IsCancellationRequested); + Assert.False(disposal.IsCompleted); + pendingRead.Release(); + await disposal.WaitAsync(Ct); + Assert.True(pendingRead.Finished.IsCompleted); + AssertPayload(retained, 1); + await Assert.ThrowsAsync(() => cursor.MoveNextAsync(Ct).AsTask()); + await Assert.ThrowsAsync(() => cursor.SeekAsync(1, Ct).AsTask()); + await cursor.DisposeAsync(); + } + finally + { + pendingRead.Release(); + if (disposal is not null) await disposal.WaitAsync(Ct); + else await cursor.DisposeAsync(); + } + } + + [Fact] + public async Task ReachingReadAheadBound_ReleasesCompletedRead_AndAllowsDemandTraversal() + { + await using var fixture = await Fixture.CreateAsync(); + await using var cursor = fixture.Tree.CreateCursor(new IndexScanRange(1, true, fixture.Leaves[1].LastKey, true)); + Assert.True(await cursor.SeekAsync(1, Ct)); + CancellationToken secondToken = Assert.Single(fixture.Device.ReadTokens(fixture.Leaves[1].PageId)); + await MoveThroughAsync(cursor, fixture.Leaves[1].FirstKey); + Assert.True(secondToken.IsCancellationRequested); + Assert.Empty(fixture.Device.ReadTokens(fixture.Leaves[2].PageId)); + await MoveThroughAsync(cursor, fixture.Leaves[2].FirstKey); + AssertPayload(cursor.CurrentValue, fixture.Leaves[2].FirstKey); + } + + private static async Task CrossLeafAsync(BTreeCursor cursor, ReadControl? control) + { + Task next = cursor.MoveNextAsync(Ct).AsTask(); + if (control is not null) + { + Assert.False(next.IsCompleted); + control.Release(); + } + Assert.True(await next); + AssertPayload(cursor.CurrentValue, cursor.CurrentKey); + } + + private static async Task MoveThroughAsync(BTreeCursor cursor, long lastKey) + { + while (cursor.CurrentKey < lastKey) + { + long expected = cursor.CurrentKey + 1; + Assert.True(await cursor.MoveNextAsync(Ct)); + Assert.Equal(expected, cursor.CurrentKey); + AssertPayload(cursor.CurrentValue, expected); + } + Assert.Equal(lastKey, cursor.CurrentKey); + } + + private static byte[] Payload(long key) + { + var value = new byte[512]; + Array.Fill(value, checked((byte)key)); + return value; + } + + private static void AssertPayload(ReadOnlyMemory value, long key) + => Assert.Equal(Payload(key), value.ToArray()); + + private sealed record Leaf(uint PageId, long FirstKey, long LastKey); + + private sealed class Fixture(Pager writer, Pager reader, WalSnapshot snapshot, + ControlledDevice device, BTree tree, IReadOnlyList leaves) : IAsyncDisposable + { + public ControlledDevice Device => device; + public BTree Tree => tree; + public IReadOnlyList Leaves => leaves; + + public static async Task CreateAsync() + { + var device = new ControlledDevice(); + var index = new WalIndex(); + var writer = await Pager.CreateAsync(device, new MemoryWriteAheadLog(index), index, + new PagerOptions { EnableSequentialLeafReadAhead = true, CheckpointPolicy = new FrameCountCheckpointPolicy(1_000_000) }, Ct); + await writer.InitializeNewDatabaseAsync(Ct); + await writer.RecoverAsync(Ct); + await writer.BeginTransactionAsync(Ct); + var tree = new BTree(writer, await BTree.CreateNewAsync(writer, Ct)); + for (int key = 1; key <= 128; key++) await tree.InsertAsync(key, Payload(key), Ct); + await writer.CommitAsync(Ct); + var leaves = new List(); + uint pageId = await tree.FindLeftmostLeafAsync(Ct); + while (pageId != PageConstants.NullPageId) + { + var page = new ReadOnlySlottedPage((await writer.GetPageReadAsync(pageId, Ct)).Memory, pageId); + leaves.Add(new(pageId, BTree.ReadLeafKey(page, 0), BTree.ReadLeafKey(page, page.CellCount - 1))); + pageId = page.RightChildOrNextLeaf; + } + Assert.True(leaves.Count >= 4); + await writer.CheckpointAsync(Ct); + var snapshot = writer.AcquireReaderSnapshot(); + var reader = writer.CreateSnapshotReader(snapshot); + device.ResetReads(); + return new(writer, reader, snapshot, device, new BTree(reader, tree.RootPageId), leaves); + } + + public async ValueTask DisposeAsync() + { + reader.Dispose(); + writer.ReleaseReaderSnapshot(snapshot); + await writer.DisposeAsync(); + } + } + + private sealed class ControlledDevice : IStorageDevice + { + private readonly MemoryStorageDevice _inner = new(); + private readonly object _gate = new(); + private readonly Dictionary _planned = []; + private readonly List<(uint PageId, CancellationToken Token)> _reads = []; + public long Length => _inner.Length; + + public ReadControl Hold(uint pageId, bool ignoreCancellation = false) + { + var control = new ReadControl(ignoreCancellation); + lock (_gate) _planned.Add(pageId, control); + return control; + } + + public IReadOnlyList ReadTokens(uint pageId) + { + lock (_gate) return _reads.Where(r => r.PageId == pageId).Select(r => r.Token).ToArray(); + } + + public void ResetReads() { lock (_gate) _reads.Clear(); } + + public ValueTask ReadAsync(long offset, Memory buffer, CancellationToken ct = default) + { + ReadControl? control; + lock (_gate) + { + uint pageId = checked((uint)(offset / PageConstants.PageSize)); + _reads.Add((pageId, ct)); + _planned.Remove(pageId, out control); + } + return control is null ? _inner.ReadAsync(offset, buffer, ct) : control.ReadAsync(_inner, offset, buffer, ct); + } + + public ValueTask WriteAsync(long offset, ReadOnlyMemory buffer, CancellationToken ct = default) => _inner.WriteAsync(offset, buffer, ct); + public ValueTask FlushAsync(CancellationToken ct = default) => _inner.FlushAsync(ct); + public ValueTask SetLengthAsync(long length, CancellationToken ct = default) => _inner.SetLengthAsync(length, ct); + public void Dispose() => _inner.Dispose(); + public ValueTask DisposeAsync() => _inner.DisposeAsync(); + } + + private sealed class ReadControl(bool ignoreCancellation) + { + private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _finished = new(TaskCreationOptions.RunContinuationsAsynchronously); + public bool Started { get; private set; } + public CancellationToken Token { get; private set; } + public Task Finished => _finished.Task; + public void Release() => _release.TrySetResult(true); + public void Fail() => _release.TrySetException(new IOException("Controlled speculative read failure.")); + + public async ValueTask ReadAsync(MemoryStorageDevice inner, long offset, Memory buffer, CancellationToken ct) + { + Started = true; + Token = ct; + try + { + await _release.Task.WaitAsync(ignoreCancellation ? CancellationToken.None : ct); + return await inner.ReadAsync(offset, buffer, ignoreCancellation ? CancellationToken.None : ct); + } + finally + { + _finished.TrySetResult(true); + } + } + } +} diff --git a/tests/CSharpDB.Tests/BTreeCursorReadAheadTests.cs b/tests/CSharpDB.Tests/BTreeCursorReadAheadTests.cs new file mode 100644 index 00000000..8ba33e2a --- /dev/null +++ b/tests/CSharpDB.Tests/BTreeCursorReadAheadTests.cs @@ -0,0 +1,136 @@ +using CSharpDB.Storage.BTrees; +using CSharpDB.Storage.Checkpointing; +using CSharpDB.Storage.Device; +using CSharpDB.Storage.Indexing; +using CSharpDB.Storage.Paging; +using CSharpDB.Storage.Wal; + +namespace CSharpDB.Tests; + +[Collection("StorageConcurrency")] +public sealed class BTreeCursorReadAheadTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Theory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public async Task UpperBoundOnCurrentLeaf_DoesNotReadNextLeaf(bool seek, bool inclusive) + { + await using var fixture = await Fixture.CreateAsync(); + await using var cursor = fixture.Tree.CreateCursor(new IndexScanRange(1, true, fixture.FirstLastKey, inclusive)); + + Assert.True(seek ? await cursor.SeekAsync(1, Ct) : await cursor.MoveNextAsync(Ct)); + Assert.Equal(1L, cursor.CurrentKey); + Assert.Equal(1, fixture.Device.ReadCount(fixture.FirstLeaf)); + Assert.Equal(0, fixture.Device.ReadCount(fixture.SecondLeaf)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task UpperBoundOnNextLeaf_PrefetchesOnlyThroughThatLeaf(bool inclusive) + { + await using var fixture = await Fixture.CreateAsync(); + await using var cursor = fixture.Tree.CreateCursor(new IndexScanRange(1, true, fixture.SecondLastKey, inclusive)); + + Assert.True(await cursor.SeekAsync(1, Ct)); + Assert.Equal(1, fixture.Device.ReadCount(fixture.SecondLeaf)); + while (cursor.CurrentKey <= fixture.FirstLastKey) + Assert.True(await cursor.MoveNextAsync(Ct)); + + Assert.Equal(fixture.FirstLastKey + 1, cursor.CurrentKey); + Assert.Equal(1, fixture.Device.ReadCount(fixture.SecondLeaf)); + Assert.Equal(0, fixture.Device.ReadCount(fixture.ThirdLeaf)); + } + + [Fact] + public async Task UnboundedCursor_StillPrefetchesNextLeaf() + { + await using var fixture = await Fixture.CreateAsync(); + await using var cursor = fixture.Tree.CreateCursor(); + Assert.True(await cursor.SeekAsync(1, Ct)); + Assert.Equal(1, fixture.Device.ReadCount(fixture.SecondLeaf)); + } + + [Fact] + public async Task ReadAheadHint_DoesNotRestrictCursorTraversalOrReseek() + { + await using var fixture = await Fixture.CreateAsync(); + await using var cursor = fixture.Tree.CreateCursor(IndexScanRange.At(1)); + var keys = new List(); + while (await cursor.MoveNextAsync(Ct)) keys.Add(cursor.CurrentKey); + Assert.Equal(Enumerable.Range(1, 128).Select(i => (long)i), keys); + Assert.True(await cursor.SeekAsync(100, Ct)); + Assert.Equal(100L, cursor.CurrentKey); + Assert.True(await cursor.MoveNextAsync(Ct)); + Assert.Equal(101L, cursor.CurrentKey); + } + + private sealed class Fixture(Pager writer, Pager reader, WalSnapshot snapshot, + CountingDevice device, BTree tree, uint firstLeaf, uint secondLeaf, uint thirdLeaf, + long firstLastKey, long secondLastKey) : IAsyncDisposable + { + public CountingDevice Device => device; + public BTree Tree => tree; + public uint FirstLeaf => firstLeaf; + public uint SecondLeaf => secondLeaf; + public uint ThirdLeaf => thirdLeaf; + public long FirstLastKey => firstLastKey; + public long SecondLastKey => secondLastKey; + + public static async Task CreateAsync() + { + var device = new CountingDevice(); + var index = new WalIndex(); + var writer = await Pager.CreateAsync(device, new MemoryWriteAheadLog(index), index, + new PagerOptions { EnableSequentialLeafReadAhead = true, CheckpointPolicy = new FrameCountCheckpointPolicy(1_000_000) }, Ct); + await writer.InitializeNewDatabaseAsync(Ct); + await writer.RecoverAsync(Ct); + await writer.BeginTransactionAsync(Ct); + var tree = new BTree(writer, await BTree.CreateNewAsync(writer, Ct)); + for (int key = 1; key <= 128; key++) await tree.InsertAsync(key, new byte[512], Ct); + await writer.CommitAsync(Ct); + uint firstId = await tree.FindLeftmostLeafAsync(Ct); + var first = new ReadOnlySlottedPage((await writer.GetPageReadAsync(firstId, Ct)).Memory, firstId); + uint secondId = first.RightChildOrNextLeaf; + var second = new ReadOnlySlottedPage((await writer.GetPageReadAsync(secondId, Ct)).Memory, secondId); + uint thirdId = second.RightChildOrNextLeaf; + Assert.NotEqual(PageConstants.NullPageId, thirdId); + long firstLast = BTree.ReadLeafKey(first, first.CellCount - 1); + long secondLast = BTree.ReadLeafKey(second, second.CellCount - 1); + await writer.CheckpointAsync(Ct); + var snapshot = writer.AcquireReaderSnapshot(); + var reader = writer.CreateSnapshotReader(snapshot); + device.Reads.Clear(); + return new(writer, reader, snapshot, device, new BTree(reader, tree.RootPageId), firstId, secondId, thirdId, firstLast, secondLast); + } + + public async ValueTask DisposeAsync() + { + reader.Dispose(); + writer.ReleaseReaderSnapshot(snapshot); + await writer.DisposeAsync(); + } + } + + private sealed class CountingDevice : IStorageDevice + { + private readonly MemoryStorageDevice _inner = new(); + public List Reads { get; } = []; + public int ReadCount(uint page) => Reads.Count(p => p == page); + public long Length => _inner.Length; + public ValueTask ReadAsync(long offset, Memory buffer, CancellationToken ct = default) + { + Reads.Add((uint)(offset / PageConstants.PageSize)); + return _inner.ReadAsync(offset, buffer, ct); + } + public ValueTask WriteAsync(long offset, ReadOnlyMemory buffer, CancellationToken ct = default) => _inner.WriteAsync(offset, buffer, ct); + public ValueTask FlushAsync(CancellationToken ct = default) => _inner.FlushAsync(ct); + public ValueTask SetLengthAsync(long length, CancellationToken ct = default) => _inner.SetLengthAsync(length, ct); + public void Dispose() => _inner.Dispose(); + public ValueTask DisposeAsync() => _inner.DisposeAsync(); + } +} diff --git a/tests/CSharpDB.Tests/CSharpDB.Tests.csproj b/tests/CSharpDB.Tests/CSharpDB.Tests.csproj index 3259a205..ce0909f5 100644 --- a/tests/CSharpDB.Tests/CSharpDB.Tests.csproj +++ b/tests/CSharpDB.Tests/CSharpDB.Tests.csproj @@ -27,14 +27,15 @@ - + - - - - + + + + + diff --git a/tests/CSharpDB.Tests/CatalogReadSnapshotChangeTests.cs b/tests/CSharpDB.Tests/CatalogReadSnapshotChangeTests.cs new file mode 100644 index 00000000..037f6e21 --- /dev/null +++ b/tests/CSharpDB.Tests/CatalogReadSnapshotChangeTests.cs @@ -0,0 +1,189 @@ +using System.Reflection; +using CSharpDB.Engine; +using CSharpDB.Primitives; +using CSharpDB.Storage.Catalog; +using CSharpDB.Storage.Paging; + +namespace CSharpDB.Tests; + +public sealed class CatalogReadSnapshotChangeTests +{ + [Fact] + public async Task DataOnlyUpdates_ShareMetadataAndKeepIndependentHeaders() + { + CancellationToken ct = TestContext.Current.CancellationToken; + await using var db = await CreateAsync(ct); + await db.BeginTransactionAsync(ct); + for (int i = 0; i < 32; i++) + await db.ExecuteAsync($"CREATE TABLE unused_{i} (id INTEGER PRIMARY KEY)", ct); + await db.CommitAsync(ct); + await db.ExecuteAsync("UPDATE t SET payload = 'warm' WHERE id = 1", ct); + SchemaCatalog catalog = Catalog(db); + CatalogReadSnapshot original = catalog.CaptureReadSnapshot(); + uint originalCounter = original.ChangeCounter; + CatalogReadSnapshot previous = original; + + for (int i = 0; i < 3; i++) + { + await db.ExecuteAsync($"UPDATE t SET payload = 'next{i}' WHERE id = 1", ct); + CatalogReadSnapshot current = catalog.CaptureReadSnapshot(previous); + Assert.Same(original.Metadata, current.Metadata); + Assert.NotEqual(previous.ChangeCounter, current.ChangeCounter); + Assert.True(current.TryGetReadTable("T", out CatalogReadTable table)); + Assert.Same(original.GetTable("t"), table.Schema); + Assert.Equal(original.GetTableRootPage("t"), table.RootPage); + previous = current; + } + Assert.Equal(originalCounter, original.ChangeCounter); + } + + [Fact] + public async Task GrowthAcrossSkippedCaptures_PreservesOldRootsAndCapturesCurrentCounts() + { + CancellationToken ct = TestContext.Current.CancellationToken; + await using var db = await CreateAsync(ct); + SchemaCatalog catalog = Catalog(db); + Pager pager = Field(db, "_pager"); + CatalogReadSnapshot original = catalog.CaptureReadSnapshot(); + uint oldIndexRoot = original.GetIndexStore("ix_t_k", pager).RootPageId; + string payload = new('x', 128); + for (int start = 2; start < 1026; start += 512) + { + await db.BeginTransactionAsync(ct); + var batch = db.PrepareInsertBatch("t", 512); + for (int i = start; i < start + 512; i++) + batch.AddRow([DbValue.FromInteger(i), DbValue.FromInteger(i * 10L), DbValue.FromText(payload)]); + await batch.ExecuteAsync(ct); + await catalog.PersistDirtyTableStatisticsAsync(ct); + await db.CommitAsync(ct); + } + + CatalogReadSnapshot current = catalog.CaptureReadSnapshot(original); + Assert.NotEqual(original.GetTableRootPage("t"), current.GetTableRootPage("t")); + Assert.NotEqual(oldIndexRoot, current.GetIndexStore("ix_t_k", pager).RootPageId); + Assert.Equal(oldIndexRoot, original.GetIndexStore("ix_t_k", pager).RootPageId); + Assert.True(original.TryGetExactTableRowCount("t", out long oldCount)); + Assert.Equal(1, oldCount); + Assert.True(current.TryGetExactTableRowCount("t", out long currentCount)); + Assert.Equal(1025, currentCount); + Assert.Equal(catalog.GetTable("t")!.NextRowId, current.GetTable("t")!.NextRowId); + } + + [Fact] + public async Task CommittedStatsChanges_SurvivePersistenceQueueClearingAndKeepOldFreshness() + { + CancellationToken ct = TestContext.Current.CancellationToken; + await using var db = await CreateAsync(ct); + SchemaCatalog catalog = Catalog(db); + catalog.ApplyCommittedAdvisoryStatisticsSnapshot( + [new TableStatistics { TableName = "t", RowCount = 1, RowCountIsExact = true }], + [ColumnStats(stale: false)], markDirty: true); + CatalogReadSnapshot original = catalog.CaptureReadSnapshot(); + Assert.True(original.TryGetFreshColumnStatistics("t", "k", out _)); + Assert.Single(original.GetColumnStatistics("t")); + + catalog.ApplyCommittedAdvisoryStatisticsSnapshot( + [new TableStatistics { TableName = "t", RowCount = 2, RowCountIsExact = false, HasStaleColumns = true }], + [ColumnStats(stale: true)], markDirty: false); + Assert.Empty(catalog.GetDirtyTableStatistics()); + Assert.Empty(catalog.GetDirtyColumnStatistics()); + CatalogReadSnapshot current = catalog.CaptureReadSnapshot(original); + Assert.False(current.TryGetExactTableRowCount("t", out _)); + Assert.True(current.TryGetEstimatedTableRowCount("t", out long estimate)); + Assert.Equal(2, estimate); + Assert.False(current.TryGetFreshColumnStatistics("t", "k", out _)); + Assert.True(Assert.Single(current.GetColumnStatistics("t")).IsStale); + Assert.True(original.TryGetExactTableRowCount("t", out long oldCount)); + Assert.Equal(1, oldCount); + Assert.False(Assert.Single(original.GetColumnStatistics("t")).IsStale); + } + + [Fact] + public async Task NextRowIdOnlyPersistence_ChangesFrozenSchemaWithoutChangingRoot() + { + CancellationToken ct = TestContext.Current.CancellationToken; + await using var db = await CreateAsync(ct); + SchemaCatalog catalog = Catalog(db); + CatalogReadSnapshot original = catalog.CaptureReadSnapshot(); + long oldNextRowId = original.GetTable("t")!.NextRowId; + await db.BeginTransactionAsync(ct); + catalog.GetTable("t")!.NextRowId = 5000; + Assert.False(await catalog.PersistAllRootPageChangesAndDetectChangesAsync(ct)); + await db.CommitAsync(ct); + CatalogReadSnapshot current = catalog.CaptureReadSnapshot(original); + + Assert.Equal(5000, current.GetTable("t")!.NextRowId); + Assert.Equal(oldNextRowId, original.GetTable("t")!.NextRowId); + Assert.Equal(original.GetTableRootPage("t"), current.GetTableRootPage("t")); + Assert.Same(original.GetForeignKeysForTable("t"), current.GetForeignKeysForTable("t")); + Assert.Same(original.GetReferencingForeignKeys("t"), current.GetReferencingForeignKeys("t")); + } + + [Fact] + public async Task OverflowedChangeJournal_CapturesEveryUpdatedTable() + { + CancellationToken ct = TestContext.Current.CancellationToken; + await using var db = await Database.OpenInMemoryAsync(ct); + await db.BeginTransactionAsync(ct); + int count = CatalogReadChanges.MaximumEntries + 2; + for (int i = 0; i < count; i++) + await db.ExecuteAsync($"CREATE TABLE t{i} (id INTEGER PRIMARY KEY)", ct); + await db.CommitAsync(ct); + SchemaCatalog catalog = Catalog(db); + CatalogReadSnapshot original = catalog.CaptureReadSnapshot(); + var hints = Enumerable.Range(0, count).Select(i => new KeyValuePair($"t{i}", 1000 + i)).ToArray(); + catalog.ApplyCommittedTableMetadataSnapshot(hints); + CatalogReadSnapshot current = catalog.CaptureReadSnapshot(original); + foreach (var hint in hints) + { + Assert.Equal(hint.Value, current.GetTable(hint.Key)!.NextRowId); + Assert.NotEqual(hint.Value, original.GetTable(hint.Key)!.NextRowId); + } + } + + [Fact] + public async Task ForeignDescriptorAndReload_RequireFullCaptureAfterSchemaChangeAndRollback() + { + CancellationToken ct = TestContext.Current.CancellationToken; + await using var db = await CreateAsync(ct); + await using var other = await CreateAsync(ct); + SchemaCatalog catalog = Catalog(db); + CatalogReadSnapshot original = catalog.CaptureReadSnapshot(); + CatalogReadSnapshot foreign = Catalog(other).CaptureReadSnapshot(); + catalog.ApplyCommittedTableMetadataSnapshot([new("t", 7000)]); + CatalogReadSnapshot fromForeign = catalog.CaptureReadSnapshot(foreign); + Assert.Equal(7000, fromForeign.GetTable("t")!.NextRowId); + Assert.NotSame(foreign.Metadata, fromForeign.Metadata); + + await db.ExecuteAsync("ALTER TABLE t ADD COLUMN added TEXT", ct); + CatalogReadSnapshot added = catalog.CaptureReadSnapshot(fromForeign); + Assert.Equal(new[] { "id", "k", "payload", "added" }, added.GetTable("t")!.Columns.Select(column => column.Name)); + Assert.Equal(3, original.GetTable("t")!.Columns.Count); + await db.BeginTransactionAsync(ct); + await db.ExecuteAsync("ALTER TABLE t ADD COLUMN rolled_back TEXT", ct); + await db.RollbackAsync(ct); + CatalogReadSnapshot restored = catalog.CaptureReadSnapshot(added); + Assert.Equal(new[] { "id", "k", "payload", "added" }, restored.GetTable("t")!.Columns.Select(column => column.Name)); + Assert.True(restored.TryGetExactTableRowCount("t", out long count)); + Assert.Equal(1, count); + } + + private static ColumnStatistics ColumnStats(bool stale) => new() + { + TableName = "t", ColumnName = "k", DistinctCount = 1, NonNullCount = 1, + MinValue = DbValue.FromInteger(10), MaxValue = DbValue.FromInteger(10), IsStale = stale, + }; + + private static async Task CreateAsync(CancellationToken ct) + { + Database db = await Database.OpenInMemoryAsync(ct); + await db.ExecuteAsync("CREATE TABLE t (id INTEGER PRIMARY KEY, k BIGINT, payload TEXT)", ct); + await db.ExecuteAsync("CREATE INDEX ix_t_k ON t(k)", ct); + await db.ExecuteAsync("INSERT INTO t VALUES (1, 10, 'initial')", ct); + return db; + } + + private static SchemaCatalog Catalog(Database db) => Field(db, "_catalog"); + private static T Field(object target, string name) + => (T)target.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(target)!; +} diff --git a/tests/CSharpDB.Tests/CheckpointConflictReadTests.cs b/tests/CSharpDB.Tests/CheckpointConflictReadTests.cs new file mode 100644 index 00000000..8dd39ad9 --- /dev/null +++ b/tests/CSharpDB.Tests/CheckpointConflictReadTests.cs @@ -0,0 +1,676 @@ +using System.Buffers.Binary; +using System.Reflection; +using CSharpDB.Engine; +using CSharpDB.Primitives; +using CSharpDB.Storage.BTrees; +using CSharpDB.Storage.Checkpointing; +using CSharpDB.Storage.Device; +using CSharpDB.Storage.Paging; +using CSharpDB.Storage.Transactions; +using CSharpDB.Storage.Wal; +using Microsoft.Win32.SafeHandles; + +namespace CSharpDB.Tests; + +[Collection("StorageConcurrency")] +public sealed class CheckpointConflictReadTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RetainedTailCompaction_WaitsForConflictRead_AndPreservesPageContents(bool staleOffsetInBounds) + { + await using var f = await Fixture.CreateAsync(); + await f.PrepareCopiedPrefixAsync(keepReader: true); + await using var tx = await f.CreateConflictingTransactionAsync(); + Assert.False(tx.State.Snapshot.HasWalFrames); // The completed prefix was filtered out. + long offset = f.LatestOffset(f.PageId); + byte[] expected = await f.Wal.ReadPageAsync(offset, f.Ct); + Assert.True(f.Wal.TryGetCheckpointRetainedWalStartOffset(out long retainedStart)); + long removedBytes = retainedStart - PageConstants.WalHeaderSize; + + if (staleOffsetInBounds) + { + // Leave enough later frames that the stale offset reads a different page, + // rather than EOF. Checking only file length would miss this corruption. + for (byte marker = 91; marker <= 93; marker++) + await f.CommitMarkerAsync(f.OtherPageId, marker); + Assert.True(offset + PageConstants.WalFrameSize <= f.WalLength - removedBytes); + byte[] displaced = await f.Wal.ReadPageAsync(offset + removedBytes, f.Ct); + Assert.Equal((byte)f.OtherPageId, displaced[129]); + Assert.NotEqual(expected[128], displaced[128]); + } + else + { + Assert.True(offset + PageConstants.WalFrameSize > f.WalLength - removedBytes); + } + + f.ReleasePrefixReader(); + f.Wal.PauseOffset = offset; + Task commit = tx.CommitAsync(f.Ct).AsTask(); + await f.Wal.ReadEntered.Task.WaitAsync(f.Ct); + int finalizeCalls = f.Wal.FinalizeCalls; + Task checkpoint = f.Pager.CheckpointAsync(f.Ct).AsTask(); + bool finalizedDuringRead = f.Wal.FinalizeCalls != finalizeCalls; + try + { + // With the old code finalization has already been entered. Finish it + // before releasing the read to deterministically expose EOF/wrong bytes. + if (finalizedDuringRead) + await checkpoint.WaitAsync(f.Ct); + else + Assert.False(checkpoint.IsCompleted); + } + finally + { + f.Wal.ResumeRead.TrySetResult(); + } + + Exception? commitError = await Record.ExceptionAsync(() => commit.WaitAsync(f.Ct)); + await checkpoint.WaitAsync(f.Ct); + if (staleOffsetInBounds) + Assert.Equal(expected, f.Wal.PausedReadContents); + Assert.Null(commitError); + Assert.Equal(expected, f.Wal.PausedReadContents); + Assert.False(finalizedDuringRead); + await f.AssertProgressAndResetAsync(); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task RetainedTailCompaction_ProtectsParentAndSplitRightReads(bool pauseSplitRight, bool dirtyParent) + { + await using var f = await Fixture.CreateAsync(); + (uint rootPageId, uint rightLeafPageId) = await f.SeedSplitTreeAsync(); + await f.PrepareCopiedPrefixAsync(keepReader: true); + await using var winner = await f.Pager.BeginWriteTransactionAsync(f.Ct); + await using var loser = await f.Pager.BeginWriteTransactionAsync(f.Ct); + Assert.False(loser.State.Snapshot.HasWalFrames); + + byte[] winnerPayload = Enumerable.Repeat((byte)0x44, 1000).ToArray(); + byte[] loserPayload = Enumerable.Repeat((byte)0x55, dirtyParent ? 1000 : 500).ToArray(); + using (winner.Bind()) + await new BTree(f.Pager, rootPageId).InsertAsync(200, winnerPayload, f.Ct); + using (loser.Bind()) + await new BTree(f.Pager, rootPageId).InsertAsync(300, loserPayload, f.Ct); + Assert.Equal(dirtyParent, loser.State.DirtyPages.Contains(rootPageId)); + await winner.CommitAsync(f.Ct); + + PageReadBuffer committedLeft = await f.Pager.ReadPageUncachedAsync(rightLeafPageId, f.Ct); + uint splitRightPageId = new ReadOnlySlottedPage(committedLeft.Memory, rightLeafPageId).RightChildOrNextLeaf; + Assert.NotEqual(PageConstants.NullPageId, splitRightPageId); + uint pausedPageId = pauseSplitRight ? splitRightPageId : rootPageId; + long offset = f.LatestOffset(pausedPageId); + byte[] expected = await f.Wal.ReadPageAsync(offset, f.Ct); + Assert.True(f.Wal.TryGetCheckpointRetainedWalStartOffset(out long retainedStart)); + long removedBytes = retainedStart - PageConstants.WalHeaderSize; + for (byte marker = 91; marker <= 93; marker++) + await f.CommitMarkerAsync(f.PageId, marker); + Assert.True(offset + PageConstants.WalFrameSize <= f.WalLength - removedBytes); + byte[] displaced = await f.Wal.ReadPageAsync(offset + removedBytes, f.Ct); + Assert.False(expected.AsSpan().SequenceEqual(displaced)); + f.ReleasePrefixReader(); + + f.Wal.PauseOffset = offset; + Task commit = loser.CommitAsync(f.Ct).AsTask(); + await f.Wal.ReadEntered.Task.WaitAsync(f.Ct); + int finalizeCalls = f.Wal.FinalizeCalls; + Task checkpoint = f.Pager.CheckpointAsync(f.Ct).AsTask(); + bool finalizedDuringRead = f.Wal.FinalizeCalls != finalizeCalls; + try + { + if (finalizedDuringRead) + await checkpoint.WaitAsync(f.Ct); + else + Assert.False(checkpoint.IsCompleted); + } + finally + { + f.Wal.ResumeRead.TrySetResult(); + } + Exception? commitError = await Record.ExceptionAsync(() => commit.WaitAsync(f.Ct)); + await checkpoint.WaitAsync(f.Ct); + Assert.Equal(expected, f.Wal.PausedReadContents); + Assert.Null(commitError); + Assert.False(finalizedDuringRead); + + await f.Pager.CheckpointAsync(f.Ct); + await f.Pager.CheckpointAsync(f.Ct); + Assert.Equal(PageConstants.WalHeaderSize, f.WalLength); + var committedTree = new BTree(f.Pager, rootPageId); + Assert.Equal(5L, await committedTree.CountEntriesAsync(f.Ct)); + Assert.Equal(Enumerable.Repeat((byte)0x11, 32), await committedTree.FindAsync(10, f.Ct)); + Assert.Equal(Enumerable.Repeat((byte)0x22, 2052), await committedTree.FindAsync(100, f.Ct)); + Assert.Equal(Enumerable.Repeat((byte)0x33, 1000), await committedTree.FindAsync(150, f.Ct)); + Assert.Equal(winnerPayload, await committedTree.FindAsync(200, f.Ct)); + Assert.Equal(loserPayload, await committedTree.FindAsync(300, f.Ct)); + Assert.Equal(0, f.Transactions.ActiveWriterCount); + } + + [Theory] + [InlineData(false, true)] + [InlineData(true, true)] + [InlineData(false, false)] + public async Task ConflictRead_ResetAndRetentionControls_PreserveContents(bool retainSnapshot, bool checkpointDuringRead) + { + await using var f = await Fixture.CreateAsync(); + if (retainSnapshot) + await f.CommitMarkerAsync(f.PageId, 11); + await using var tx = await f.CreateConflictingTransactionAsync(); + Assert.Equal(retainSnapshot, tx.State.Snapshot.HasWalFrames); + long offset = f.LatestOffset(f.PageId); + byte[] expected = await f.Wal.ReadPageAsync(offset, f.Ct); + f.Wal.PauseOffset = offset; + Task commit = tx.CommitAsync(f.Ct).AsTask(); + await f.Wal.ReadEntered.Task.WaitAsync(f.Ct); + long length = f.WalLength; + int finalizeCalls = f.Wal.FinalizeCalls; + try + { + if (checkpointDuringRead) + { + await f.Pager.CheckpointAsync(f.Ct); + Assert.True(f.Wal.HasPendingCheckpoint); + Assert.Equal(length, f.WalLength); + Assert.Equal(finalizeCalls, f.Wal.FinalizeCalls); + } + } + finally + { + f.Wal.ResumeRead.TrySetResult(); + } + await commit.WaitAsync(f.Ct); + Assert.Equal(expected, f.Wal.PausedReadContents); + await f.AssertProgressAndResetAsync(); + } + + [Fact] + public async Task CopyCompleteReset_TakesCommitLockBeforeBeginBarrier() + { + await using var f = await Fixture.CreateAsync(); + await f.PrepareCopiedPrefixAsync(); + IDisposable commitLock = await f.Transactions.AcquireCommitLockAsync(f.Ct); + int finalizeCalls = f.Wal.FinalizeCalls; + Task checkpoint = f.Pager.CheckpointAsync(f.Ct).AsTask(); + try + { + Assert.False(checkpoint.IsCompleted); + Assert.Equal(finalizeCalls, f.Wal.FinalizeCalls); + // A finalizer that takes begin first deadlocks an explicit commit that + // already owns commit and needs to enter its pending-commit window. + using var begin = await f.Transactions.AcquireBeginBarrierAsync(f.Ct); + } + finally + { + commitLock.Dispose(); + } + await checkpoint.WaitAsync(f.Ct); + Assert.Equal(PageConstants.WalHeaderSize, f.WalLength); + await f.AssertProgressAndResetAsync(); + } + + [Theory] + [InlineData("commit")] + [InlineData("begin")] + [InlineData("writer")] + [InlineData("finalize")] + public async Task CancelledFinalization_ReleasesAcquiredLocks_AndAllowsProgress(string blockedAt) + { + await using var f = await Fixture.CreateAsync(); + await f.PrepareCopiedPrefixAsync(); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(f.Ct); + IDisposable? held = null; + if (blockedAt == "commit") + held = await f.Transactions.AcquireCommitLockAsync(f.Ct); + else if (blockedAt == "begin") + held = await f.Transactions.AcquireBeginBarrierAsync(f.Ct); + else if (blockedAt == "writer") + await f.Pager.BeginTransactionAsync(f.Ct); + else + f.Wal.BeforeFinalize = ct => new ValueTask(Task.Delay(Timeout.Infinite, ct)); + + Task checkpoint = f.Pager.CheckpointAsync(cancellation.Token).AsTask(); + try + { + Assert.False(checkpoint.IsCompleted); + if (blockedAt == "finalize") + await f.Wal.FinalizeEntered.Task.WaitAsync(f.Ct); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => checkpoint.WaitAsync(f.Ct)); + } + finally + { + held?.Dispose(); + f.Wal.BeforeFinalize = null; + if (blockedAt == "writer") + await f.Pager.RollbackAsync(f.Ct); + } + await f.AssertProgressAndResetAsync(); + } + + [Fact] + public async Task FinalizationFailure_ReleasesAllLocks_AndAllowsRetry() + { + await using var f = await Fixture.CreateAsync(); + await f.PrepareCopiedPrefixAsync(); + f.Wal.BeforeFinalize = _ => throw new IOException("Injected checkpoint finalization failure."); + await Assert.ThrowsAsync(() => f.Pager.CheckpointAsync(f.Ct).AsTask()); + f.Wal.BeforeFinalize = null; + await f.AssertProgressAndResetAsync(); + } + + [Fact] + public async Task CancelledConflictRead_ReleasesCommitLock_AndUnblocksCompaction() + { + await using var f = await Fixture.CreateAsync(); + await f.PrepareCopiedPrefixAsync(keepReader: true); + await using var tx = await f.CreateConflictingTransactionAsync(); + f.ReleasePrefixReader(); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(f.Ct); + f.Wal.PauseOffset = f.LatestOffset(f.PageId); + Task commit = tx.CommitAsync(cancellation.Token).AsTask(); + await f.Wal.ReadEntered.Task.WaitAsync(f.Ct); + Task checkpoint = f.Pager.CheckpointAsync(f.Ct).AsTask(); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => commit.WaitAsync(f.Ct)); + await checkpoint.WaitAsync(f.Ct); + Assert.True(tx.State.Completed); + await f.AssertProgressAndResetAsync(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Finalization_WithPendingDurableCommit_AllowsCompletion(bool legacyWriter) + { + var flush = new PausingFlushPolicy(); + await using var f = await Fixture.CreateAsync(flush); + await f.PrepareCopiedPrefixAsync(); + await using var tx = legacyWriter ? null : await f.Pager.BeginWriteTransactionAsync(f.Ct); + if (legacyWriter) + await f.Pager.BeginTransactionAsync(f.Ct); + using (tx?.Bind()) + await f.SetMarkerAsync(f.PageId, 25); + + flush.Armed = true; + Task append = legacyWriter + ? f.Pager.BeginCommitAsync(f.Ct).AsTask() + : tx!.BeginCommitAsync(f.Ct).AsTask(); + await flush.Entered.Task.WaitAsync(f.Ct); + int finalizeCalls = f.Wal.FinalizeCalls; + Task checkpoint = f.Pager.CheckpointAsync(f.Ct).AsTask(); + try + { + if (legacyWriter) + { + await checkpoint.WaitAsync(f.Ct); + Assert.True(f.Wal.HasPendingCheckpoint); + Assert.Equal(finalizeCalls, f.Wal.FinalizeCalls); + } + else + { + Assert.False(checkpoint.IsCompleted); + } + } + finally + { + flush.Resume.TrySetResult(); + } + await (await append.WaitAsync(f.Ct)).WaitAsync(f.Ct); + await checkpoint.WaitAsync(f.Ct); + await f.AssertProgressAndResetAsync(); + } + + [Fact] + public async Task Finalization_WaitingForLegacyWriter_DoesNotBlockItsCommit() + { + await using var f = await Fixture.CreateAsync(); + await f.PrepareCopiedPrefixAsync(); + await f.Pager.BeginTransactionAsync(f.Ct); + await f.SetMarkerAsync(f.PageId, 25); + Task checkpoint = f.Pager.CheckpointAsync(f.Ct).AsTask(); + Assert.False(checkpoint.IsCompleted); + await f.Pager.CommitAsync(f.Ct); + await checkpoint.WaitAsync(f.Ct); + await f.AssertProgressAndResetAsync(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Finalization_HoldsCommitLockThroughStateRefresh_AndReleasesIt(bool cancel) + { + await using var f = await Fixture.CreateAsync(); + await f.PrepareCopiedPrefixAsync(keepReader: true); + await f.CommitMarkerAsync(f.PageId, 25); + f.ReleasePrefixReader(); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(f.Ct); + f.Wal.AfterFinalize = () => f.Wal.PauseOffset = f.LatestOffset(0); + Task checkpoint = f.Pager.CheckpointAsync(cancellation.Token).AsTask(); + await f.Wal.ReadEntered.Task.WaitAsync(f.Ct); + Task commitLock = f.Transactions.AcquireCommitLockAsync(f.Ct).AsTask(); + try + { + Assert.False(commitLock.IsCompleted); + } + finally + { + f.Wal.AfterFinalize = null; + if (cancel) + cancellation.Cancel(); + else + f.Wal.ResumeRead.TrySetResult(); + } + if (cancel) + await Assert.ThrowsAnyAsync(() => checkpoint.WaitAsync(f.Ct)); + else + await checkpoint.WaitAsync(f.Ct); + (await commitLock.WaitAsync(f.Ct)).Dispose(); + await f.AssertProgressAndResetAsync(); + } + + [Theory] + [InlineData(0)] + [InlineData(250)] + public async Task BackgroundCheckpoint_EightSqlWriters_PreserveAllRowsAfterReopen(int batchMicroseconds) + { + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + deadline.CancelAfter(TimeSpan.FromSeconds(60)); + CancellationToken ct = deadline.Token; + string path = Path.Combine(Path.GetTempPath(), $"csharpdb_checkpoint_writers_{Guid.NewGuid():N}.db"); + var options = new DatabaseOptions().ConfigureStorageEngine(builder => + { + builder.UseDurableCommitBatchWindow(TimeSpan.FromMicroseconds(batchMicroseconds)); + builder.UsePagerOptions(new PagerOptions + { + CheckpointPolicy = new FrameCountCheckpointPolicy(16), + AutoCheckpointExecutionMode = AutoCheckpointExecutionMode.Background, + AutoCheckpointMaxPagesPerStep = 4, + MaxCachedPages = 16, + MaxCachedWalReadPages = 0, + }); + }); + try + { + await using (var db = await Database.OpenAsync(path, options, ct)) + { + await db.ExecuteAsync("CREATE TABLE writes (id INTEGER PRIMARY KEY, writer INTEGER, sequence INTEGER)", ct); + var start = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Task[] writers = Enumerable.Range(0, 8).Select(writer => Task.Run(async () => + { + await start.Task.WaitAsync(ct); + for (int sequence = 0; sequence < 24; sequence++) + { + await db.RunWriteTransactionAsync(async (tx, innerCt) => + { + await tx.ExecuteAsync($"INSERT INTO writes (writer, sequence) VALUES ({writer}, {sequence})", innerCt); + }, new WriteTransactionOptions { MaxRetries = 40 }, ct); + } + }, ct)).ToArray(); + start.SetResult(); + await Task.WhenAll(writers).WaitAsync(ct); + await db.CheckpointAsync(ct); + } + + await using var reopened = await Database.OpenAsync(path, options, ct); + await using var result = await reopened.ExecuteAsync("SELECT id, writer, sequence FROM writes", ct); + var rows = await result.ToListAsync(ct); + Assert.Equal(192, rows.Count); + Assert.Equal(192, rows.Select(row => row[0].AsInteger).Distinct().Count()); + Assert.Equal( + Enumerable.Range(0, 8).SelectMany(writer => Enumerable.Range(0, 24).Select(sequence => ((long)writer, (long)sequence))), + rows.Select(row => (row[1].AsInteger, row[2].AsInteger)).OrderBy(row => row.Item1).ThenBy(row => row.Item2)); + } + finally + { + File.Delete(path); + File.Delete(path + ".wal"); + } + } + + private sealed class Fixture : IAsyncDisposable + { + private readonly CancellationTokenSource _deadline = + CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + private readonly string _path = Path.Combine(Path.GetTempPath(), $"csharpdb_checkpoint_conflict_{Guid.NewGuid():N}.db"); + public CancellationToken Ct => _deadline.Token; + public WalIndex Index { get; } = new(); + public PausingWal Wal { get; private set; } = null!; + public Pager Pager { get; private set; } = null!; + public uint PageId { get; private set; } + public uint OtherPageId { get; private set; } + private WalSnapshot? _prefixReader; + public long WalLength => new FileInfo(_path + ".wal").Length; + public TransactionCoordinator Transactions => (TransactionCoordinator)typeof(Pager) + .GetField("_transactions", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(Pager)!; + + public static async Task CreateAsync(IWalFlushPolicy? flush = null) + { + var f = new Fixture(); + f._deadline.CancelAfter(TimeSpan.FromSeconds(30)); + f.Wal = new PausingWal(new WriteAheadLog(f._path, f.Index, checksumProvider: null, + flushPolicy: flush ?? DurableWalFlushPolicy.Instance, + durableCommitBatchWindow: TimeSpan.FromMilliseconds(0.25))); + f.Pager = await Pager.CreateAsync(new FileStorageDevice(f._path, createNew: true), f.Wal, f.Index, + new PagerOptions + { + CheckpointPolicy = new FrameCountCheckpointPolicy(int.MaxValue), + MaxCachedWalReadPages = 0, + }, f.Ct); + await f.Pager.InitializeNewDatabaseAsync(f.Ct); + await f.Pager.BeginTransactionAsync(f.Ct); + f.PageId = await f.Pager.AllocatePageAsync(f.Ct); + f.OtherPageId = await f.Pager.AllocatePageAsync(f.Ct); + f.Pager.SchemaRootPage = f.PageId; + await f.SetMarkerAsync(f.PageId, 10); + await f.SetMarkerAsync(f.OtherPageId, 90); + await f.Pager.CommitAsync(f.Ct); + await f.Pager.CheckpointAsync(f.Ct); + return f; + } + + public async Task PrepareCopiedPrefixAsync(bool keepReader = false) + { + await CommitMarkerAsync(PageId, 11); + WalSnapshot pin = Pager.AcquireReaderSnapshot(); + try + { + await Pager.CheckpointAsync(Ct); + Assert.True(Wal.HasPendingCheckpoint); + Assert.True(Wal.IsCheckpointCopyComplete); + } + finally + { + if (keepReader) + _prefixReader = pin; + else + Pager.ReleaseReaderSnapshot(pin); + } + } + + public async Task<(uint RootPageId, uint RightLeafPageId)> SeedSplitTreeAsync() + { + await Pager.BeginTransactionAsync(Ct); + uint leftLeafPageId = await Pager.AllocatePageAsync(Ct); + uint rightLeafPageId = await Pager.AllocatePageAsync(Ct); + uint rootPageId = await Pager.AllocatePageAsync(Ct); + Pager.SchemaRootPage = rootPageId; + + var left = new SlottedPage(await Pager.GetPageAsync(leftLeafPageId, Ct), leftLeafPageId); + left.Initialize(PageConstants.PageTypeLeaf); + left.RightChildOrNextLeaf = rightLeafPageId; + Assert.True(left.InsertCell(0, BTree.BuildLeafCell(10, Enumerable.Repeat((byte)0x11, 32).ToArray()))); + await Pager.MarkDirtyAsync(leftLeafPageId, Ct); + + var right = new SlottedPage(await Pager.GetPageAsync(rightLeafPageId, Ct), rightLeafPageId); + right.Initialize(PageConstants.PageTypeLeaf); + Assert.True(right.InsertCell(0, BTree.BuildLeafCell(100, Enumerable.Repeat((byte)0x22, 2052).ToArray()))); + Assert.True(right.InsertCell(1, BTree.BuildLeafCell(150, Enumerable.Repeat((byte)0x33, 1000).ToArray()))); + await Pager.MarkDirtyAsync(rightLeafPageId, Ct); + + var root = new SlottedPage(await Pager.GetPageAsync(rootPageId, Ct), rootPageId); + root.Initialize(PageConstants.PageTypeInterior); + root.RightChildOrNextLeaf = rightLeafPageId; + byte[] interiorCell = new byte[13]; + interiorCell[0] = 12; + BinaryPrimitives.WriteUInt32LittleEndian(interiorCell.AsSpan(1, sizeof(uint)), leftLeafPageId); + BinaryPrimitives.WriteInt64LittleEndian(interiorCell.AsSpan(5, sizeof(long)), 100); + Assert.True(root.InsertCell(0, interiorCell)); + await Pager.MarkDirtyAsync(rootPageId, Ct); + + await Pager.CommitAsync(Ct); + await Pager.CheckpointAsync(Ct); + return (rootPageId, rightLeafPageId); + } + + public void ReleasePrefixReader() + { + if (_prefixReader is { } reader) + { + _prefixReader = null; + Pager.ReleaseReaderSnapshot(reader); + } + } + + public async Task CreateConflictingTransactionAsync() + { + var tx = await Pager.BeginWriteTransactionAsync(Ct); + using (tx.Bind()) + await SetMarkerAsync(PageId, 25); + for (byte marker = 21; marker <= 25; marker++) + await CommitMarkerAsync(PageId, marker); + // Its base version is still read from the original snapshot. + PageReadBuffer original = await Pager.GetSnapshotPageReadAsync(PageId, tx.State.Snapshot, Ct); + Assert.Contains(original.Memory.Span[128], new byte[] { 10, 11 }); + return tx; + } + + public long LatestOffset(uint pageId) + { + Assert.True(Index.TryGetLatest(pageId, out long offset)); + return offset; + } + + public async Task SetMarkerAsync(uint pageId, byte marker) + { + byte[] page = await Pager.GetPageAsync(pageId, Ct); + page[128] = marker; + page[129] = (byte)pageId; + await Pager.MarkDirtyAsync(pageId, Ct); + } + + public async Task CommitMarkerAsync(uint pageId, byte marker) + { + await using var tx = await Pager.BeginWriteTransactionAsync(Ct); + using (tx.Bind()) + await SetMarkerAsync(pageId, marker); + await tx.CommitAsync(Ct); + } + + public async Task AssertProgressAndResetAsync() + { + // Check the conflict's exact contents before another write can hide it. + PageReadBuffer page = await Pager.ReadPageUncachedAsync(PageId, Ct); + Assert.Equal((byte)PageId, page.Memory.Span[129]); + await CommitMarkerAsync(PageId, 31); + await Pager.CheckpointAsync(Ct); // Finish the old prefix, if still pending. + await Pager.CheckpointAsync(Ct); // Copy/reset any retained tail. + Assert.False(Wal.HasPendingCheckpoint); + Assert.Equal(PageConstants.WalHeaderSize, WalLength); + Assert.Equal(0, Index.FrameCount); + page = await Pager.ReadPageUncachedAsync(PageId, Ct); + Assert.Equal((byte)31, page.Memory.Span[128]); + Assert.Equal((byte)PageId, page.Memory.Span[129]); + Assert.Equal(0, Transactions.ActiveWriterCount); + } + + public async ValueTask DisposeAsync() + { + Wal.ResumeRead.TrySetResult(); + Wal.BeforeFinalize = null; + ReleasePrefixReader(); + await Pager.DisposeAsync(); + _deadline.Dispose(); + File.Delete(_path); + File.Delete(_path + ".wal"); + } + } + + private sealed class PausingFlushPolicy : IWalFlushPolicy + { + public bool Armed; + public bool AllowsWriteConcurrencyDuringCommitFlush => true; + public bool PerformsDurableFlush => true; + public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Resume { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public async ValueTask FlushCommitAsync(SafeFileHandle handle, CancellationToken cancellationToken) + { + if (Armed) + { + Armed = false; + Entered.TrySetResult(); + await Resume.Task.WaitAsync(cancellationToken); + } + RandomAccess.FlushToDisk(handle); + } + } + + // Forward the copy-plan capability too, so the test uses production snapshot + // copy-safety decisions. Only the I/O scheduling is controlled by the test. + private sealed class PausingWal(WriteAheadLog inner) : IWriteAheadLog, IWalCheckpointCopyPlan + { + public long PauseOffset = -1; + public int FinalizeCalls; + public byte[]? PausedReadContents; + public Func? BeforeFinalize; + public Action? AfterFinalize; + public TaskCompletionSource ReadEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource ResumeRead { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource FinalizeEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public bool HasPendingCheckpoint => inner.HasPendingCheckpoint; + public bool IsCheckpointCopyComplete => inner.IsCheckpointCopyComplete; + public bool HasPendingCommitWork => inner.HasPendingCommitWork; + public bool IsOpen => inner.IsOpen; + public bool TryGetCheckpointRetainedWalStartOffset(out long offset) => inner.TryGetCheckpointRetainedWalStartOffset(out offset); + public ValueTask>> PrepareCheckpointCopyAsync(uint count, CancellationToken ct) => ((IWalCheckpointCopyPlan)inner).PrepareCheckpointCopyAsync(count, ct); + public ValueTask OpenAsync(uint count, CancellationToken ct = default) => inner.OpenAsync(count, ct); + public void BeginTransaction() => inner.BeginTransaction(); + public ValueTask AppendFrameAsync(uint id, ReadOnlyMemory page, CancellationToken ct = default) => inner.AppendFrameAsync(id, page, ct); + public ValueTask AppendFramesAsync(ReadOnlyMemory frames, CancellationToken ct = default) => inner.AppendFramesAsync(frames, ct); + public ValueTask AppendFramesAndCommitAsync(ReadOnlyMemory frames, uint count, CancellationToken ct = default) => inner.AppendFramesAndCommitAsync(frames, count, ct); + public ValueTask CommitAsync(uint count, CancellationToken ct = default) => inner.CommitAsync(count, ct); + public ValueTask RollbackAsync(CancellationToken ct = default) => inner.RollbackAsync(ct); + public ValueTask ReadPageAsync(long offset, CancellationToken ct = default) => inner.ReadPageAsync(offset, ct); + public async ValueTask ReadPageIntoAsync(long offset, Memory destination, CancellationToken ct = default) + { + bool paused = Interlocked.CompareExchange(ref PauseOffset, -1, offset) == offset; + if (paused) + { + ReadEntered.TrySetResult(); + await ResumeRead.Task.WaitAsync(ct); + } + await inner.ReadPageIntoAsync(offset, destination, ct); + if (paused) + PausedReadContents = destination.ToArray(); + } + public ValueTask CheckpointStepAsync(IStorageDevice device, uint count, int max, CancellationToken ct = default, bool allowFinalize = true) => inner.CheckpointStepAsync(device, count, max, ct, allowFinalize); + public async ValueTask CheckpointAsync(IStorageDevice device, uint count, CancellationToken ct = default, bool allowFinalize = true) + { + if (allowFinalize) + { + Interlocked.Increment(ref FinalizeCalls); + FinalizeEntered.TrySetResult(); + if (BeforeFinalize is { } before) + await before(ct); + } + await inner.CheckpointAsync(device, count, ct, allowFinalize); + if (allowFinalize) + AfterFinalize?.Invoke(); + } + public ValueTask CloseAndDeleteAsync() => inner.CloseAndDeleteAsync(); + public ValueTask DisposeAsync() => inner.DisposeAsync(); + } +} diff --git a/tests/CSharpDB.Tests/DatabaseConcurrencyTests.cs b/tests/CSharpDB.Tests/DatabaseConcurrencyTests.cs index d1f6cb28..c465f7ff 100644 --- a/tests/CSharpDB.Tests/DatabaseConcurrencyTests.cs +++ b/tests/CSharpDB.Tests/DatabaseConcurrencyTests.cs @@ -1893,7 +1893,7 @@ public async Task ActiveExplicitWriteTransaction_BlocksManualCheckpointUntilTran } [Fact] - public async Task ActiveExplicitWriteTransaction_AllowsBackgroundCheckpointFinalizeWhenSnapshotDoesNotRetainWal() + public async Task ActiveExplicitWriteTransaction_WithEmptySnapshotDefersBackgroundCopyUntilSnapshotDrains() { var ct = TestContext.Current.CancellationToken; string dbPath = Path.Combine(Path.GetTempPath(), $"csharpdb_concurrency_background_checkpoint_test_{Guid.NewGuid():N}.db"); @@ -1915,7 +1915,8 @@ public async Task ActiveExplicitWriteTransaction_AllowsBackgroundCheckpointFinal await using (var tx = await db.BeginWriteTransactionAsync(ct)) { - await tx.ExecuteAsync("SELECT COUNT(*) FROM tx_background_checkpoint", ct); + await using (var original = await tx.ExecuteAsync("SELECT COUNT(*) FROM tx_background_checkpoint", ct)) + Assert.Equal(0L, Assert.Single(await original.ToListAsync(ct))[0].AsInteger); await db.ExecuteAsync("INSERT INTO tx_background_checkpoint VALUES (1, 10)", ct); var appendDiagnostics = db.GetCommitPathDiagnosticsSnapshot(); @@ -1923,19 +1924,16 @@ public async Task ActiveExplicitWriteTransaction_AllowsBackgroundCheckpointFinal appendDiagnostics.WalAppendCount > 0, $"Expected the insert to append WAL frames, observed walAppendCount={appendDiagnostics.WalAppendCount}."); - await WaitForConditionAsync( - () => db.GetCommitPathDiagnosticsSnapshot().BackgroundCheckpointStartCount > 0, - TimeSpan.FromSeconds(5), - ct); - - var commitDiagnostics = db.GetCommitPathDiagnosticsSnapshot(); + // This snapshot has no WAL version of the changed table page: + // copying it would replace the transaction's old base-file view. Assert.True( - commitDiagnostics.BackgroundCheckpointStartCount > 0, - $"Expected background checkpoint copying to start while the explicit write transaction was active, observed backgroundCheckpointStarts={commitDiagnostics.BackgroundCheckpointStartCount}."); - - await WaitForWalLengthAsync(walPath, PageConstants.WalHeaderSize, TimeSpan.FromSeconds(5), ct); + new FileInfo(walPath).Length > PageConstants.WalHeaderSize, + "Expected WAL frames to remain while the explicit transaction needs unchanged base pages."); + await using var snapshotRows = await tx.ExecuteAsync("SELECT id, value FROM tx_background_checkpoint ORDER BY id", ct); + Assert.Empty(await snapshotRows.ToListAsync(ct)); } + // Releasing the last snapshot must resume the deferred automatic work. await WaitForWalLengthAsync(walPath, PageConstants.WalHeaderSize, TimeSpan.FromSeconds(5), ct); await using var result = await db.ExecuteAsync("SELECT COUNT(*) FROM tx_background_checkpoint", ct); diff --git a/tests/CSharpDB.Tests/LimitBatchForwardingTests.cs b/tests/CSharpDB.Tests/LimitBatchForwardingTests.cs new file mode 100644 index 00000000..a0f22595 --- /dev/null +++ b/tests/CSharpDB.Tests/LimitBatchForwardingTests.cs @@ -0,0 +1,412 @@ +using CSharpDB.Execution; +using CSharpDB.Primitives; + +namespace CSharpDB.Tests; + +public sealed class LimitBatchForwardingTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Theory] + [InlineData(true, 17)] + [InlineData(false, 17)] + [InlineData(true, 64)] + [InlineData(false, 64)] + [InlineData(true, 63)] + [InlineData(false, 63)] + public async Task UntouchedSourceBatchThatFits_IsForwarded(bool sourceReusesBatch, int batchSize) + { + var source = new CursorBatchSource(batchSize * 2, sourceReusesBatch, batchSize); + await using var limit = new LimitOperator(source, batchSize * 2); + var batches = (IBatchOperator)limit; + await limit.OpenAsync(Ct); + + for (int first = 0; first < batchSize * 2; first += batchSize) + { + Assert.True(await batches.MoveNextBatchAsync(Ct)); + Assert.Same(source.CurrentBatch, batches.CurrentBatch); + AssertRows(batches.CurrentBatch, first, batchSize); + } + Assert.False(await batches.MoveNextBatchAsync(Ct)); + } + + [Theory] + [InlineData(64, true)] + [InlineData(65, true)] + [InlineData(128, true)] + [InlineData(130, true)] + [InlineData(64, false)] + [InlineData(65, false)] + [InlineData(128, false)] + [InlineData(130, false)] + public async Task LimitAtBatchBoundaries_ReturnsExactlyTheRequestedRows(int rowLimit, bool sourceReusesBatch) + { + var source = new CursorBatchSource(200, sourceReusesBatch, 64); + await using var limit = new LimitOperator(source, rowLimit); + await limit.OpenAsync(Ct); + + long[] rows = await DrainAsync((IBatchOperator)limit); + Assert.Equal(Enumerable.Range(0, rowLimit).Select(value => (long)value), rows); + Assert.False(await limit.MoveNextAsync(Ct)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CurrentBatchObtainedBeforeAdvancing_RemainsTheFirstOutputBuffer(bool sourceReusesBatch) + { + var source = new CursorBatchSource(128, sourceReusesBatch, 64); + await using var limit = new LimitOperator(source, 128); + var batches = (IBatchOperator)limit; + await limit.OpenAsync(Ct); + RowBatch empty = batches.CurrentBatch; + Assert.Equal(0, empty.Count); + + Assert.True(await batches.MoveNextBatchAsync(Ct)); + Assert.Same(empty, batches.CurrentBatch); + Assert.NotSame(source.CurrentBatch, batches.CurrentBatch); + AssertRows(batches.CurrentBatch, 0, 64); + Assert.Equal(Enumerable.Range(64, 64).Select(value => (long)value), await DrainAsync(batches)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task LargeSourceBatches_CarryCopiedRemaindersAcrossOutputBoundaries(bool sourceReusesBatch) + { + var source = new CursorBatchSource(350, sourceReusesBatch, 130); + await using var limit = new LimitOperator(source, 260); + var batches = (IBatchOperator)limit; + await limit.OpenAsync(Ct); + Assert.True(await batches.MoveNextBatchAsync(Ct)); + Assert.NotSame(source.CurrentBatch, batches.CurrentBatch); + AssertRows(batches.CurrentBatch, 0, 64); + Assert.Equal(Enumerable.Range(64, 196).Select(value => (long)value), await DrainAsync(batches)); + Assert.False(await limit.MoveNextAsync(Ct)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ReuseDisabled_RetainedOutputsSurviveSubsequentReadsAndReopen(bool sourceReusesBatch) + { + var source = new CursorBatchSource(200, sourceReusesBatch, 64); + await using var limit = new LimitOperator(source, 130); + var batches = (IBatchOperator)limit; + await limit.OpenAsync(Ct); + limit.SetReuseCurrentBatch(false); + Assert.False(batches.ReusesCurrentBatch); + var retained = new List<(RowBatch Batch, int First, int Count)>(); + int next = 0; + while (await batches.MoveNextBatchAsync(Ct)) + { + RowBatch output = batches.CurrentBatch; + Assert.DoesNotContain(retained, previous => ReferenceEquals(previous.Batch, output)); + if (sourceReusesBatch) Assert.NotSame(source.CurrentBatch, output); + else if (output.Count == 64) Assert.Same(source.CurrentBatch, output); + AssertRows(output, next, output.Count); + retained.Add((output, next, output.Count)); + next += output.Count; + } + Assert.Equal(130, next); + + await limit.DisposeAsync(); + await limit.OpenAsync(Ct); + limit.SetReuseCurrentBatch(true); + Assert.Equal(Enumerable.Range(0, 130).Select(value => (long)value), await DrainAsync(batches)); + foreach (var previous in retained) + AssertRows(previous.Batch, previous.First, previous.Count); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ReuseTrueFalseTrue_DoesNotMutateOutputRetainedWhileReuseWasDisabled(bool sourceReusesBatch) + { + var source = new CursorBatchSource(256, sourceReusesBatch, 64); + await using var limit = new LimitOperator(source, 256); + var batches = (IBatchOperator)limit; + await limit.OpenAsync(Ct); + Assert.True(await batches.MoveNextBatchAsync(Ct)); + Assert.Same(source.CurrentBatch, batches.CurrentBatch); + AssertRows(batches.CurrentBatch, 0, 64); + + limit.SetReuseCurrentBatch(false); + Assert.True(await batches.MoveNextBatchAsync(Ct)); + RowBatch retained = batches.CurrentBatch; + AssertRows(retained, 64, 64); + limit.SetReuseCurrentBatch(true); + Assert.True(await batches.MoveNextBatchAsync(Ct)); + Assert.NotSame(retained, batches.CurrentBatch); + AssertRows(batches.CurrentBatch, 128, 64); + Assert.True(await batches.MoveNextBatchAsync(Ct)); + Assert.NotSame(retained, batches.CurrentBatch); + AssertRows(batches.CurrentBatch, 192, 64); + Assert.False(await batches.MoveNextBatchAsync(Ct)); + AssertRows(retained, 64, 64); + } + + [Fact] + public async Task ReopenAfterForwarding_RestartsAtTheFirstRow() + { + var source = new CursorBatchSource(200, reuseBatch: true, 64); + await using var limit = new LimitOperator(source, 130); + var batches = (IBatchOperator)limit; + await limit.OpenAsync(Ct); + Assert.True(await batches.MoveNextBatchAsync(Ct)); + Assert.Same(source.CurrentBatch, batches.CurrentBatch); + AssertRows(batches.CurrentBatch, 0, 64); + + await limit.OpenAsync(Ct); + Assert.Equal(Enumerable.Range(0, 130).Select(value => (long)value), await DrainAsync(batches)); + } + + [Fact] + public async Task ZeroLimit_DoesNotAdvanceTheSource() + { + var source = new CursorBatchSource(10, reuseBatch: true, 64); + await using var limit = new LimitOperator(source, 0); + var batches = (IBatchOperator)limit; + await limit.OpenAsync(Ct); + Assert.False(await batches.MoveNextBatchAsync(Ct)); + Assert.False(await limit.MoveNextAsync(Ct)); + Assert.Equal(0, batches.CurrentBatch.Count); + Assert.Equal(0, source.BatchReadCalls); + Assert.Equal(0, source.RowReadCalls); + } + + [Fact] + public async Task EmptySource_DoesNotReturnAnEmptySuccessfulBatch() + { + var source = new CursorBatchSource(0, reuseBatch: true, 64); + await using var limit = new LimitOperator(source, 130); + var batches = (IBatchOperator)limit; + await limit.OpenAsync(Ct); + Assert.False(await batches.MoveNextBatchAsync(Ct)); + Assert.Equal(0, batches.CurrentBatch.Count); + } + + [Fact] + public async Task EmptyIntermediateSourceBatch_DoesNotEndTheRead() + { + var source = new CursorBatchSource(34, reuseBatch: true, 17, 0, 17); + await using var limit = new LimitOperator(source, 34); + var batches = (IBatchOperator)limit; + await limit.OpenAsync(Ct); + Assert.True(await batches.MoveNextBatchAsync(Ct)); + AssertRows(batches.CurrentBatch, 0, 17); + Assert.True(await batches.MoveNextBatchAsync(Ct)); + Assert.Same(source.CurrentBatch, batches.CurrentBatch); + AssertRows(batches.CurrentBatch, 17, 17); + Assert.Equal(3, source.BatchReadCalls); + Assert.False(await batches.MoveNextBatchAsync(Ct)); + } + + [Fact] + public async Task AsynchronouslyCompletedSourceBatch_IsForwarded() + { + var source = new CursorBatchSource(64, reuseBatch: true, 64) { CompleteAsynchronously = true }; + await using var limit = new LimitOperator(source, 64); + var batches = (IBatchOperator)limit; + await limit.OpenAsync(Ct); + Assert.True(await batches.MoveNextBatchAsync(Ct)); + Assert.Same(source.CurrentBatch, batches.CurrentBatch); + AssertRows(batches.CurrentBatch, 0, 64); + Assert.False(await batches.MoveNextBatchAsync(Ct)); + } + + [Fact] + public async Task CanceledAsynchronousBatchRead_DoesNotConsumeRows() + { + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var source = new CursorBatchSource(64, reuseBatch: true, 64) + { + CompleteAsynchronously = true, + AsyncBatchGate = gate, + }; + await using var limit = new LimitOperator(source, 64); + var batches = (IBatchOperator)limit; + await limit.OpenAsync(Ct); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(Ct); + ValueTask pending = batches.MoveNextBatchAsync(cancellation.Token); + await source.AsyncBatchStarted.Task.WaitAsync(Ct); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => pending.AsTask()); + + gate.SetResult(true); + Assert.True(await batches.MoveNextBatchAsync(Ct)); + AssertRows(batches.CurrentBatch, 0, 64); + Assert.False(await batches.MoveNextBatchAsync(Ct)); + } + + [Fact] + public async Task RowReadsBetweenFullyForwardedBatches_PreserveCursorOrder() + { + var source = new CursorBatchSource(300, reuseBatch: true, 64); + await using var limit = new LimitOperator(source, 194); + var batches = (IBatchOperator)limit; + await limit.OpenAsync(Ct); + Assert.True(await limit.MoveNextAsync(Ct)); + AssertRow(limit.Current, 0); + Assert.True(await batches.MoveNextBatchAsync(Ct)); + Assert.Same(source.CurrentBatch, batches.CurrentBatch); + AssertRows(batches.CurrentBatch, 1, 64); + Assert.True(await limit.MoveNextAsync(Ct)); + AssertRow(limit.Current, 65); + Assert.True(await batches.MoveNextBatchAsync(Ct)); + Assert.Same(source.CurrentBatch, batches.CurrentBatch); + AssertRows(batches.CurrentBatch, 66, 64); + Assert.True(await limit.MoveNextAsync(Ct)); + AssertRow(limit.Current, 130); + Assert.True(await batches.MoveNextBatchAsync(Ct)); + Assert.NotSame(source.CurrentBatch, batches.CurrentBatch); + AssertRows(batches.CurrentBatch, 131, 63); + Assert.False(await limit.MoveNextAsync(Ct)); + Assert.False(await batches.MoveNextBatchAsync(Ct)); + } + + [Fact] + public async Task RowReadsAfterAnOwnedCopyAndForwardedBatch_PreserveCursorOrder() + { + var source = new CursorBatchSource(300, reuseBatch: true, 64); + await using var limit = new LimitOperator(source, 194); + var batches = (IBatchOperator)limit; + await limit.OpenAsync(Ct); + RowBatch owned = batches.CurrentBatch; + Assert.True(await batches.MoveNextBatchAsync(Ct)); + Assert.Same(owned, batches.CurrentBatch); + Assert.NotSame(source.CurrentBatch, batches.CurrentBatch); + AssertRows(batches.CurrentBatch, 0, 64); + + Assert.True(await limit.MoveNextAsync(Ct)); + AssertRow(limit.Current, 64); + Assert.True(await batches.MoveNextBatchAsync(Ct)); + AssertRows(batches.CurrentBatch, 65, 64); + Assert.True(await limit.MoveNextAsync(Ct)); + AssertRow(limit.Current, 129); + Assert.True(await batches.MoveNextBatchAsync(Ct)); + AssertRows(batches.CurrentBatch, 130, 64); + Assert.False(await limit.MoveNextAsync(Ct)); + Assert.False(await batches.MoveNextBatchAsync(Ct)); + } + + private static async Task DrainAsync(IBatchOperator batches) + { + var ids = new List(); + while (await batches.MoveNextBatchAsync(Ct)) + { + RowBatch batch = batches.CurrentBatch; + Assert.True(batch.Count > 0); + for (int row = 0; row < batch.Count; row++) + { + long id = batch.GetRowSpan(row)[0].AsInteger; + Assert.Equal(-id - 1, batch.GetRowSpan(row)[1].AsInteger); + ids.Add(id); + } + } + return ids.ToArray(); + } + + private static void AssertRows(RowBatch batch, int first, int count) + { + Assert.Equal(2, batch.ColumnCount); + Assert.Equal(count, batch.Count); + for (int row = 0; row < count; row++) AssertRow(batch.GetRowSpan(row), first + row); + } + + private static void AssertRow(ReadOnlySpan row, int id) + { + Assert.Equal(2, row.Length); + Assert.Equal((long)id, row[0].AsInteger); + Assert.Equal(-(long)id - 1, row[1].AsInteger); + } + + // Row reads advance the underlying cursor without changing CurrentBatch, as + // PrimaryKeyRangeScanOperator does. This catches stale-batch forwarding. + private sealed class CursorBatchSource(int rowCount, bool reuseBatch, params int[] batchSizes) : IOperator, IBatchOperator + { + private int _next; + private int _batchNumber; + private RowBatch? _currentBatch; + + public ColumnDefinition[] OutputSchema { get; } = + [ + new() { Name = "id", Type = DbType.Integer }, + new() { Name = "value", Type = DbType.Integer }, + ]; + public bool ReusesCurrentRowBuffer => false; + public bool ReusesCurrentBatch => reuseBatch; + public DbValue[] Current { get; private set; } = []; + public RowBatch CurrentBatch => _currentBatch ??= new RowBatch(2, 0); + public int BatchReadCalls { get; private set; } + public int RowReadCalls { get; private set; } + public bool CompleteAsynchronously { get; init; } + public TaskCompletionSource? AsyncBatchGate { get; init; } + public TaskCompletionSource AsyncBatchStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public ValueTask OpenAsync(CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + _next = 0; + _batchNumber = 0; + _currentBatch = null; + Current = []; + BatchReadCalls = 0; + RowReadCalls = 0; + return ValueTask.CompletedTask; + } + + public ValueTask MoveNextAsync(CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + RowReadCalls++; + if (_next >= rowCount) return ValueTask.FromResult(false); + Current = MakeRow(_next++); + return ValueTask.FromResult(true); + } + + public ValueTask MoveNextBatchAsync(CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + BatchReadCalls++; + return CompleteAsynchronously ? MoveNextBatchAfterYieldAsync(ct) : ValueTask.FromResult(MoveNextBatchCore()); + } + + private async ValueTask MoveNextBatchAfterYieldAsync(CancellationToken ct) + { + AsyncBatchStarted.TrySetResult(true); + if (AsyncBatchGate is { } gate) await gate.Task.WaitAsync(ct); + else await Task.Yield(); + ct.ThrowIfCancellationRequested(); + return MoveNextBatchCore(); + } + + private bool MoveNextBatchCore() + { + if (_next >= rowCount) + { + _currentBatch = null; + return false; + } + + int capacity = batchSizes[_batchNumber++ % batchSizes.Length]; + RowBatch batch = reuseBatch && _currentBatch is { } current && current.Capacity == capacity + ? current + : new RowBatch(2, capacity); + batch.Reset(); + while (batch.Count < batch.Capacity && _next < rowCount) + batch.AppendRow(MakeRow(_next++)); + _currentBatch = batch; + return true; + } + + public ValueTask DisposeAsync() + { + _currentBatch = null; + Current = []; + return ValueTask.CompletedTask; + } + + private static DbValue[] MakeRow(int id) => [DbValue.FromInteger(id), DbValue.FromInteger(-(long)id - 1)]; + } +} diff --git a/tests/CSharpDB.Tests/LimitMixedTraversalTests.cs b/tests/CSharpDB.Tests/LimitMixedTraversalTests.cs new file mode 100644 index 00000000..64409848 --- /dev/null +++ b/tests/CSharpDB.Tests/LimitMixedTraversalTests.cs @@ -0,0 +1,466 @@ +using CSharpDB.Execution; +using CSharpDB.Primitives; + +namespace CSharpDB.Tests; + +public sealed class LimitMixedTraversalTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Theory] + [InlineData(65)] + [InlineData(128)] + [InlineData(130)] + public async Task RowAfterPartialBatch_ReadsBufferedRowsBeforeSourceEof(int sourceBatchSize) + { + var source = new CursorSource(sourceBatchSize, sourceBatchSize); + await using var limit = new LimitOperator(source, sourceBatchSize); + await limit.OpenAsync(Ct); + var ids = new List(); + + await ReadBatchAsync(limit, ids); + Assert.Equal(64, ids.Count); + while (ids.Count < sourceBatchSize) + await ReadRowAsync(limit, ids); + + AssertSequence(ids, sourceBatchSize); + await AssertEofAsync(limit); + } + + [Theory] + [InlineData(false, 65, true)] + [InlineData(false, 128, true)] + [InlineData(false, 130, true)] + [InlineData(true, 64, true)] + [InlineData(true, 65, true)] + [InlineData(true, 128, true)] + [InlineData(true, 130, true)] + [InlineData(false, 130, false)] + [InlineData(true, 130, false)] + public async Task AlternatingBatchesAndRows_RespectsOrderAndFiniteLimit( + bool throughAdapter, int sourceBatchSize, bool sourceReusesBatch) + { + var source = new CursorSource(400, sourceBatchSize, sourceReusesBatch); + await using var limit = new LimitOperator(AsRowSource(source, throughAdapter), 197); + await limit.OpenAsync(Ct); + var ids = new List(); + + while (ids.Count < 197) + { + await ReadBatchAsync(limit, ids); + if (ids.Count < 197) + await ReadRowAsync(limit, ids); + } + + AssertSequence(ids, 197); + await AssertEofAsync(limit); + } + + [Theory] + [InlineData(64)] + [InlineData(65)] + [InlineData(130)] + public async Task AdapterRowsBeforeBatch_DoNotReplayAlreadyConsumedRows(int sourceBatchSize) + { + var source = new CursorSource(250, sourceBatchSize); + await using var limit = new LimitOperator(new BatchToRowOperatorAdapter(source), 135); + await limit.OpenAsync(Ct); + var ids = new List(); + + await ReadRowAsync(limit, ids); + await ReadRowAsync(limit, ids); + await ReadBatchAsync(limit, ids); + await ReadRowAsync(limit, ids); + await DrainBatchesAsync(limit, ids); + + AssertSequence(ids, 135); + await AssertEofAsync(limit); + } + + [Theory] + [InlineData(1)] + [InlineData(4)] + public async Task SmallAdapterLimit_RowThenBatchSharesOneCursor(int rowLimit) + { + var source = new CursorSource(100, 65); + await using var limit = new LimitOperator(new BatchToRowOperatorAdapter(source), rowLimit); + await limit.OpenAsync(Ct); + var ids = new List(); + + await ReadRowAsync(limit, ids); + if (rowLimit > 1) + await ReadBatchAsync(limit, ids); + + AssertSequence(ids, rowLimit); + await AssertEofAsync(limit); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ReuseDisabled_RetainedRowsAndBatchesSurviveMixedTraversal(bool throughAdapter) + { + var source = new CursorSource(350, 130); + await using var limit = new LimitOperator(AsRowSource(source, throughAdapter), 260); + limit.SetReuseCurrentRowBuffer(false); + limit.SetReuseCurrentBatch(false); + await limit.OpenAsync(Ct); + Assert.False(limit.ReusesCurrentRowBuffer); + Assert.False(((IBatchOperator)limit).ReusesCurrentBatch); + var ids = new List(); + + await ReadBatchAsync(limit, ids); + RowBatch retainedBatch = ((IBatchOperator)limit).CurrentBatch; + await ReadRowAsync(limit, ids); + DbValue[] row64 = limit.Current; + await ReadRowAsync(limit, ids); + DbValue[] row65 = limit.Current; + Assert.NotSame(row64, row65); + await ReadBatchAsync(limit, ids); + await ReadRowAsync(limit, ids); + await DrainBatchesAsync(limit, ids); + + AssertSequence(ids, 260); + AssertRow(row64, 64); + AssertRow(row65, 65); + AssertRows(retainedBatch, 0, 64); + await AssertEofAsync(limit); + AssertRow(row64, 64); + AssertRow(row65, 65); + AssertRows(retainedBatch, 0, 64); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ReuseEnabledAfterOwnedRow_DoesNotMutateThatRetainedRow(bool throughAdapter) + { + var source = new CursorSource(350, 130); + await using var limit = new LimitOperator(AsRowSource(source, throughAdapter), 200); + await limit.OpenAsync(Ct); + var ids = new List(); + await ReadBatchAsync(limit, ids); + await ReadRowAsync(limit, ids); + + limit.SetReuseCurrentRowBuffer(false); + await ReadRowAsync(limit, ids); + DbValue[] retained = limit.Current; + limit.SetReuseCurrentRowBuffer(true); + await ReadRowAsync(limit, ids); + await ReadBatchAsync(limit, ids); + await ReadRowAsync(limit, ids); + await DrainBatchesAsync(limit, ids); + + AssertSequence(ids, 200); + AssertRow(retained, 65); + await AssertEofAsync(limit); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ReopenWithBufferedRows_RestartsBothTraversalModes(bool throughAdapter) + { + var source = new CursorSource(250, 130); + await using var limit = new LimitOperator(AsRowSource(source, throughAdapter), 165); + await limit.OpenAsync(Ct); + var abandoned = new List(); + await ReadBatchAsync(limit, abandoned); + await ReadRowAsync(limit, abandoned); + + await limit.OpenAsync(Ct); + var ids = new List(); + await ReadRowAsync(limit, ids); + await ReadBatchAsync(limit, ids); + await ReadRowAsync(limit, ids); + await DrainBatchesAsync(limit, ids); + + AssertSequence(ids, 165); + await AssertEofAsync(limit); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CanceledRefillAfterMixedReads_DoesNotConsumeRows(bool throughAdapter) + { + var source = new CursorSource(200, 65); + await using var limit = new LimitOperator(AsRowSource(source, throughAdapter), 130); + await limit.OpenAsync(Ct); + var ids = new List(); + await ReadBatchAsync(limit, ids); + await ReadRowAsync(limit, ids); + Assert.Equal(65, ids.Count); + + Task started = source.BlockNextBatchRead(); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(Ct); + ValueTask pending = ((IBatchOperator)limit).MoveNextBatchAsync(cancellation.Token); + await started.WaitAsync(Ct); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => pending.AsTask()); + + await ReadRowAsync(limit, ids); + await DrainBatchesAsync(limit, ids); + AssertSequence(ids, 130); + await AssertEofAsync(limit); + + await limit.OpenAsync(Ct); + var reopened = new List(); + await ReadRowAsync(limit, reopened); + await ReadBatchAsync(limit, reopened); + AssertSequence(reopened, reopened.Count); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task SuspendedDirectRowFailure_RetryDoesNotConsumeTheLimit(bool cancel) + { + var source = new CursorSource(6, 65); + await using var limit = new LimitOperator(source, 4); + await limit.OpenAsync(Ct); + TaskCompletionSource gate = source.SuspendNextRowRead(); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(Ct); + + ValueTask pending = limit.MoveNextAsync(cancellation.Token); + Assert.False(pending.IsCompleted); + await source.RowReadStarted.Task.WaitAsync(Ct); + if (cancel) + { + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => pending.AsTask()); + gate.SetResult(true); + } + else + { + var failure = new InvalidOperationException("Injected direct-row failure."); + gate.SetException(failure); + Assert.Same(failure, + await Assert.ThrowsAsync(() => pending.AsTask())); + } + + var ids = new List(); + for (int i = 0; i < 4; i++) + await ReadRowAsync(limit, ids); + + AssertSequence(ids, 4); + Assert.False(await limit.MoveNextAsync(Ct)); + Assert.Equal(5, source.RowReadCalls); // One failed attempt and four successful reads. + } + + [Theory] + [InlineData(0)] + [InlineData(2)] + public async Task DirectSourceEndsBeforeLimit_ReopenRestartsTheRows(int sourceRowCount) + { + var source = new CursorSource(sourceRowCount, 65); + await using var limit = new LimitOperator(source, 4); + + for (int pass = 0; pass < 2; pass++) + { + await limit.OpenAsync(Ct); + var ids = new List(); + for (int i = 0; i < sourceRowCount; i++) + await ReadRowAsync(limit, ids); + + AssertSequence(ids, sourceRowCount); + Assert.False(await limit.MoveNextAsync(Ct)); + Assert.False(await limit.MoveNextAsync(Ct)); + } + } + + private static IOperator AsRowSource(CursorSource source, bool throughAdapter) + => throughAdapter ? new BatchToRowOperatorAdapter(source) : source; + + private static async Task ReadRowAsync(LimitOperator limit, List ids) + { + Assert.True(await limit.MoveNextAsync(Ct)); + AssertRow(limit.Current, ids.Count); + ids.Add(limit.Current[0].AsInteger); + } + + private static async Task ReadBatchAsync(LimitOperator limit, List ids) + { + var batches = (IBatchOperator)limit; + Assert.True(await batches.MoveNextBatchAsync(Ct)); + AppendBatch(batches.CurrentBatch, ids); + } + + private static async Task DrainBatchesAsync(LimitOperator limit, List ids) + { + var batches = (IBatchOperator)limit; + while (await batches.MoveNextBatchAsync(Ct)) + AppendBatch(batches.CurrentBatch, ids); + } + + private static void AppendBatch(RowBatch batch, List ids) + { + Assert.InRange(batch.Count, 1, 64); + for (int i = 0; i < batch.Count; i++) + { + AssertRow(batch.GetRowSpan(i), ids.Count); + ids.Add(batch.GetRowSpan(i)[0].AsInteger); + } + } + + private static async Task AssertEofAsync(LimitOperator limit) + { + Assert.False(await limit.MoveNextAsync(Ct)); + Assert.False(await ((IBatchOperator)limit).MoveNextBatchAsync(Ct)); + Assert.False(await limit.MoveNextAsync(Ct)); + } + + private static void AssertSequence(List ids, int count) + => Assert.Equal(Enumerable.Range(0, count).Select(value => (long)value), ids); + + private static void AssertRows(RowBatch batch, int first, int count) + { + Assert.Equal(count, batch.Count); + for (int i = 0; i < count; i++) + AssertRow(batch.GetRowSpan(i), first + i); + } + + private static void AssertRow(ReadOnlySpan row, int id) + { + Assert.Equal(2, row.Length); + Assert.Equal((long)id, row[0].AsInteger); + Assert.Equal(-(long)id - 1, row[1].AsInteger); + } + + // Mirrors a physical scan: reading a batch advances its storage cursor for + // every row in that batch. Direct row reads continue from that cursor and + // leave CurrentBatch unchanged. The adapter adds its own row position. + private sealed class CursorSource(int rowCount, int batchSize, bool reuseBatch = true) : + IOperator, IBatchOperator, IRowBufferReuseController + { + private int _next; + private bool _reuseRow = true; + private DbValue[]? _rowBuffer; + private RowBatch? _batch; + private TaskCompletionSource? _nextRowGate; + private TaskCompletionSource? _nextBatchGate; + private TaskCompletionSource? _batchStarted; + + public ColumnDefinition[] OutputSchema { get; } = + [ + new() { Name = "id", Type = DbType.Integer }, + new() { Name = "value", Type = DbType.Integer }, + ]; + public bool ReusesCurrentRowBuffer => _reuseRow; + public bool ReusesCurrentBatch => reuseBatch; + public DbValue[] Current { get; private set; } = []; + public RowBatch CurrentBatch => _batch ??= new RowBatch(2, batchSize); + public int RowReadCalls { get; private set; } + public TaskCompletionSource RowReadStarted { get; private set; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public ValueTask OpenAsync(CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + _next = 0; + _batch = null; + _rowBuffer = null; + _nextRowGate = null; + RowReadCalls = 0; + RowReadStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + _nextBatchGate = null; + _batchStarted = null; + Current = []; + return ValueTask.CompletedTask; + } + + public ValueTask MoveNextAsync(CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + RowReadCalls++; + if (_nextRowGate is not { } gate) + return ValueTask.FromResult(ReadRow()); + + _nextRowGate = null; + RowReadStarted.TrySetResult(true); + return ReadRowAfterGateAsync(gate.Task, ct); + } + + public TaskCompletionSource SuspendNextRowRead() + { + _nextRowGate = new(TaskCreationOptions.RunContinuationsAsynchronously); + RowReadStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + return _nextRowGate; + } + + private async ValueTask ReadRowAfterGateAsync(Task gate, CancellationToken ct) + { + await gate.WaitAsync(ct); + ct.ThrowIfCancellationRequested(); + return ReadRow(); + } + + private bool ReadRow() + { + if (_next >= rowCount) return false; + DbValue[] row = _reuseRow ? (_rowBuffer ??= new DbValue[2]) : new DbValue[2]; + WriteRow(row, _next++); + Current = row; + return true; + } + + public ValueTask MoveNextBatchAsync(CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + if (_nextBatchGate is not { } gate) + return ValueTask.FromResult(ReadBatch()); + + _nextBatchGate = null; + _batchStarted!.TrySetResult(true); + return ReadAfterGateAsync(gate.Task, ct); + } + + public Task BlockNextBatchRead() + { + _nextBatchGate = new(TaskCreationOptions.RunContinuationsAsynchronously); + _batchStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + return _batchStarted.Task; + } + + private async ValueTask ReadAfterGateAsync(Task gate, CancellationToken ct) + { + await gate.WaitAsync(ct); + ct.ThrowIfCancellationRequested(); + return ReadBatch(); + } + + private bool ReadBatch() + { + RowBatch batch = reuseBatch ? CurrentBatch : new RowBatch(2, batchSize); + batch.Reset(); + while (_next < rowCount && batch.Count < batch.Capacity) + { + int rowIndex = batch.Count; + WriteRow(batch.GetWritableRowSpan(rowIndex), _next++); + batch.CommitWrittenRow(rowIndex); + } + _batch = batch; + return batch.Count > 0; + } + + public void SetReuseCurrentRowBuffer(bool reuse) + { + _reuseRow = reuse; + if (!reuse) _rowBuffer = null; + } + + public ValueTask DisposeAsync() + { + _batch = null; + _rowBuffer = null; + Current = []; + return ValueTask.CompletedTask; + } + + private static void WriteRow(Span row, int id) + { + row[0] = DbValue.FromInteger(id); + row[1] = DbValue.FromInteger(-(long)id - 1); + } + } +} diff --git a/tests/CSharpDB.Tests/MemoryMappedPointLookupTests.cs b/tests/CSharpDB.Tests/MemoryMappedPointLookupTests.cs new file mode 100644 index 00000000..1e8c8aee --- /dev/null +++ b/tests/CSharpDB.Tests/MemoryMappedPointLookupTests.cs @@ -0,0 +1,278 @@ +using System.Reflection; +using CSharpDB.Engine; +using CSharpDB.Primitives; +using CSharpDB.Storage.Catalog; +using CSharpDB.Storage.Checkpointing; +using CSharpDB.Storage.Paging; +using CSharpDB.Storage.StorageEngine; +using CSharpDB.Storage.Wal; + +namespace CSharpDB.Tests; + +public sealed class MemoryMappedPointLookupTests +{ + private const string Label = "unchanged base-file row"; + private static readonly byte[] Payload = [0, 1, 127, 128, 254, 255]; + private static readonly TimeSpan OperationTimeout = TimeSpan.FromSeconds(10); + + [Theory(Timeout = 30_000)] + [InlineData(false)] + [InlineData(true)] + public async Task PointLookup_PreservesBasePageAcrossConcurrentCheckpointGrowth(bool mapped) + { + CancellationToken ct = TestContext.Current.CancellationToken; + string path = CreatePath(); + var interceptor = new PausedPageReadInterceptor(); + DatabaseOptions options = CreateOptions(mapped, interceptor); + DbValue[] retainedRow = []; + try + { + await SeedAndCloseAsync(path, options, ct); + // Reopen so the unchanged table leaf is not left in the writer's owned cache. + await using (var db = await Database.OpenAsync(path, options, ct)) + { + Pager pager = GetField(db, "_pager"); + uint root = GetField(db, "_catalog").GetTableRootPage("a"); + long baseLength = new FileInfo(path).Length; + await GrowUnrelatedTableAsync(db, ct); + AssertGrowthIsStillInWal(pager, path, baseLength); + + // Capturing after growth lets checkpoint copy every pending WAL target, + // while table a still depends on the unchanged main-file leaf. + using (var reader = db.CreateReaderSession()) + { + AssertBaseFileSnapshot(GetField(reader, "_snapshot"), root); + interceptor.Arm(root); + retainedRow = await RunAcrossCheckpointAsync( + db, path, baseLength, interceptor, mapped, + async operationCt => + { + await using var result = await reader.ExecuteReadAsync( + "SELECT * FROM a WHERE id = 1", operationCt); + return Assert.Single(await result.ToListAsync(operationCt)); + }, ct); + AssertOriginalRow(retainedRow); + } + + await db.CheckpointAsync(ct); + Assert.False(GetField(pager, "_wal").HasPendingCheckpoint); + } + + // These values must remain owned after both the snapshot and mapping are disposed. + AssertOriginalRow(retainedRow); + } + finally { DeleteFiles(path); } + } + + [Theory(Timeout = 30_000)] + [InlineData(false)] + [InlineData(true)] + public async Task ExplicitTransactionClone_PreservesBasePageAcrossConcurrentCheckpointGrowth(bool mapped) + { + CancellationToken ct = TestContext.Current.CancellationToken; + string path = CreatePath(); + var interceptor = new PausedPageReadInterceptor(); + DatabaseOptions options = CreateOptions(mapped, interceptor); + try + { + uint root = await SeedAndCloseAsync(path, options, ct); + // Read the expected bytes while the database is closed; file sharing differs + // between Windows and Unix, and this must not warm the pager's cache. + byte[] expectedPage = await ReadBasePageAsync(path, root, ct); + await using var db = await Database.OpenAsync(path, options, ct); + Pager pager = GetField(db, "_pager"); + Assert.Equal(root, GetField(db, "_catalog").GetTableRootPage("a")); + long baseLength = new FileInfo(path).Length; + await GrowUnrelatedTableAsync(db, ct); + AssertGrowthIsStillInWal(pager, path, baseLength); + + await using (var transaction = await pager.BeginWriteTransactionAsync(ct)) + { + AssertBaseFileSnapshot(transaction.State.Snapshot, root); + interceptor.Arm(root); + byte[] clonedPage = await RunAcrossCheckpointAsync( + db, path, baseLength, interceptor, mapped, + async operationCt => + { + // Binding only this task leaves manual checkpoint outside the transaction. + using var binding = transaction.Bind(); + return await pager.GetPageAsync(root, operationCt); + }, ct); + + Assert.Equal(expectedPage, clonedPage); + Assert.Same(clonedPage, transaction.State.ModifiedPages[root]); + await transaction.RollbackAsync(ct); + Assert.Equal(expectedPage, clonedPage); + } + + await db.CheckpointAsync(ct); + Assert.False(GetField(pager, "_wal").HasPendingCheckpoint); + } + finally { DeleteFiles(path); } + } + + private static async Task RunAcrossCheckpointAsync( + Database db, + string path, + long baseLength, + PausedPageReadInterceptor interceptor, + bool mapped, + Func> operation, + CancellationToken ct) + { + using var operationCancellation = CancellationTokenSource.CreateLinkedTokenSource(ct); + operationCancellation.CancelAfter(TimeSpan.FromSeconds(20)); + Task? pendingRead = null; + try + { + // A cache-only interceptor callback may synchronously wait; give the read its + // own task so the test can always release the gate in its finally block. + pendingRead = Task.Run(() => operation(operationCancellation.Token), operationCancellation.Token); + PageReadSource source = await interceptor.Reached.WaitAsync(OperationTimeout, ct); + // The gate is based on page identity, not buffer ownership: an owned-copy fix + // must still run through exactly this checkpoint interleaving. + Assert.True(source == PageReadSource.StorageDevice || + (mapped && source == PageReadSource.MemoryMappedMainFile), + $"Expected a cold main-file read (mapped={mapped}), but observed {source}."); + + await db.CheckpointAsync(operationCancellation.Token); + IWriteAheadLog wal = GetField(GetField(db, "_pager"), "_wal"); + Assert.True(wal.HasPendingCheckpoint); + Assert.True(wal.IsCheckpointCopyComplete); + Assert.True(new FileInfo(path).Length > baseLength, + "Checkpoint must grow the main file while the page read is paused."); + + interceptor.Release(); + return await pendingRead.WaitAsync(OperationTimeout, ct); + } + finally + { + interceptor.Release(); + if (pendingRead is not null) + { + if (!pendingRead.IsCompleted) + operationCancellation.Cancel(); + try { await pendingRead.WaitAsync(OperationTimeout); } + catch when (pendingRead.IsCompleted) + { + // Observe any failure before disposing the reader/transaction/database; + // preserve the original test failure rather than replacing it here. + } + } + } + } + + private static DatabaseOptions CreateOptions(bool mapped, IPageOperationInterceptor interceptor) => new() + { + StorageEngineOptions = new StorageEngineOptions + { + PagerOptions = new PagerOptions + { + UseMemoryMappedReads = mapped, + CheckpointPolicy = new FrameCountCheckpointPolicy(10_000), + Interceptors = [interceptor], + }, + }, + }; + + private static async Task SeedAndCloseAsync(string path, DatabaseOptions options, CancellationToken ct) + { + await using var db = await Database.OpenAsync(path, options, ct); + await ExecuteAsync(db, "CREATE TABLE a (id INTEGER PRIMARY KEY, label TEXT, payload BLOB)", ct); + await ExecuteAsync(db, $"INSERT INTO a VALUES (1, '{Label}', X'{Convert.ToHexString(Payload)}')", ct); + await ExecuteAsync(db, "CREATE TABLE b (id INTEGER PRIMARY KEY, padding TEXT)", ct); + await db.CheckpointAsync(ct); + return GetField(db, "_catalog").GetTableRootPage("a"); + } + + private static async Task GrowUnrelatedTableAsync(Database db, CancellationToken ct) + { + await db.BeginTransactionAsync(ct); + string padding = new('x', 512); + for (int id = 0; id < 64; id++) + await ExecuteAsync(db, $"INSERT INTO b VALUES ({id}, '{padding}')", ct); + await db.CommitAsync(ct); + } + + private static void AssertGrowthIsStillInWal(Pager pager, string path, long baseLength) + { + Assert.Equal(baseLength, new FileInfo(path).Length); + Assert.True((long)pager.PageCount * PageConstants.PageSize > baseLength); + } + + private static void AssertBaseFileSnapshot(WalSnapshot snapshot, uint root) + { + Assert.True(snapshot.HasWalFrames); + Assert.False(snapshot.TryGet(root, out _)); + } + + private static async Task ReadBasePageAsync(string path, uint pageId, CancellationToken ct) + { + await using var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + file.Position = (long)pageId * PageConstants.PageSize; + byte[] page = new byte[PageConstants.PageSize]; + await file.ReadExactlyAsync(page, ct); + return page; + } + + private static async Task ExecuteAsync(Database db, string sql, CancellationToken ct) + { + await using var result = await db.ExecuteAsync(sql, ct); + } + + private static void AssertOriginalRow(DbValue[] row) + { + Assert.Equal(3, row.Length); + Assert.Equal(1L, row[0].AsInteger); + Assert.Equal(Label, row[1].AsText); + Assert.Equal(Payload, row[2].AsBlob); + } + + private static T GetField(object instance, string name) + => Assert.IsAssignableFrom(instance.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(instance)); + + private static string CreatePath() + => Path.Combine(Path.GetTempPath(), $"csharpdb_mapped_point_{Guid.NewGuid():N}.db"); + + private static void DeleteFiles(string path) + { + foreach (string file in new[] { path, path + ".wal", path + ".lock" }) + if (File.Exists(file)) File.Delete(file); + } + + private sealed class PausedPageReadInterceptor : IPageOperationInterceptor + { + private readonly TaskCompletionSource _reached = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously); + private uint _pageId; + private int _armed; + + public Task Reached => _reached.Task; + + public void Arm(uint pageId) + { + _pageId = pageId; + Volatile.Write(ref _armed, 1); + } + + public void Release() => _release.TrySetResult(); + + public async ValueTask OnAfterReadAsync(uint pageId, PageReadSource source, CancellationToken ct = default) + { + if (pageId != _pageId || Interlocked.CompareExchange(ref _armed, 0, 1) != 1) + return; + _reached.TrySetResult(source); + await _release.Task.WaitAsync(ct); + } + + public ValueTask OnBeforeReadAsync(uint pageId, CancellationToken ct = default) => ValueTask.CompletedTask; + public ValueTask OnBeforeWriteAsync(uint pageId, CancellationToken ct = default) => ValueTask.CompletedTask; + public ValueTask OnAfterWriteAsync(uint pageId, bool succeeded, CancellationToken ct = default) => ValueTask.CompletedTask; + public ValueTask OnCommitStartAsync(int dirtyPageCount, CancellationToken ct = default) => ValueTask.CompletedTask; + public ValueTask OnCommitEndAsync(int dirtyPageCount, bool succeeded, CancellationToken ct = default) => ValueTask.CompletedTask; + public ValueTask OnCheckpointStartAsync(int committedFrameCount, CancellationToken ct = default) => ValueTask.CompletedTask; + public ValueTask OnCheckpointEndAsync(int committedFrameCount, bool succeeded, CancellationToken ct = default) => ValueTask.CompletedTask; + public ValueTask OnRecoveryStartAsync(CancellationToken ct = default) => ValueTask.CompletedTask; + public ValueTask OnRecoveryEndAsync(bool succeeded, CancellationToken ct = default) => ValueTask.CompletedTask; + } +} diff --git a/tests/CSharpDB.Tests/MemoryMappedProviderBoundaryTests.cs b/tests/CSharpDB.Tests/MemoryMappedProviderBoundaryTests.cs new file mode 100644 index 00000000..b4879da0 --- /dev/null +++ b/tests/CSharpDB.Tests/MemoryMappedProviderBoundaryTests.cs @@ -0,0 +1,294 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using CSharpDB.Storage.Device; +using CSharpDB.Storage.Paging; +using Microsoft.Win32.SafeHandles; + +namespace CSharpDB.Tests; + +public sealed class MemoryMappedProviderBoundaryTests +{ + [Fact] + public async Task CopiedReadOnlyPage_SurvivesSourceMutationRemapAndProviderDisposal() + { + var ct = TestContext.Current.CancellationToken; + string path = NewTempPath(); + byte[] original = PatternPage(19); + byte[] changed = PatternPage(71); + try + { + await using var device = new FileStorageDevice(path, createNew: true); + await device.WriteAsync(0, original, ct); + using var provider = new MemoryMappedPageReadProvider(device, copyOnRead: true); + PageReadBuffer retained = await provider.ReadPageAsync(0, ct); + Assert.False(retained.TryGetOwnedBuffer(out _)); + byte[] mutable = retained.MaterializeOwnedBuffer(); + mutable[0] ^= 0xff; + Assert.Equal(original, retained.Memory.ToArray()); + + await device.WriteAsync(0, changed, ct); + await device.WriteAsync(PageConstants.PageSize, changed, ct); + provider.RefreshMapping(); + Assert.Equal(original, retained.Memory.ToArray()); + Assert.Equal(changed, (await provider.ReadPageAsync(0, ct)).Memory.ToArray()); + byte[] owned = await provider.ReadOwnedPageAsync(0, ct); + owned[0] ^= 0xff; + Assert.Equal(changed, (await provider.ReadPageAsync(0, ct)).Memory.ToArray()); + + provider.Dispose(); + Assert.Equal(original, retained.Memory.ToArray()); + Assert.Equal((byte)(original[0] ^ 0xff), mutable[0]); + Assert.Equal((byte)(changed[0] ^ 0xff), owned[0]); + } + finally { File.Delete(path); } + } + + [Fact] + public async Task GrowthBeyondMappedLength_UsesOwnedFallbackUntilRefresh() + { + var ct = TestContext.Current.CancellationToken; + string path = NewTempPath(); + byte[] first = PatternPage(17); + byte[] appended = PatternPage(83); + + try + { + await using var device = new FileStorageDevice(path, createNew: true); + await device.WriteAsync(0, first, ct); + using var provider = new MemoryMappedPageReadProvider(device); + PageReadBuffer originalView = await provider.ReadPageAsync(0, ct); + AssertMapped(first, originalView); + byte[] originalCopy = await provider.ReadOwnedPageAsync(0, ct); + + // Append through the device rather than SetLength: Windows does not + // support truncating a file while its mapped views are still open. + await device.WriteAsync(PageConstants.PageSize, appended, ct); + Assert.Equal(2L * PageConstants.PageSize, device.Length); + + PageReadBuffer fallback = await provider.ReadPageAsync(1, ct); + AssertOwned(appended, fallback); + AssertMapped(first, await provider.ReadPageAsync(0, ct)); + + provider.RefreshMapping(); + + AssertMapped(first, await provider.ReadPageAsync(0, ct)); + AssertMapped(appended, await provider.ReadPageAsync(1, ct)); + Assert.Throws(() => ReadFirstByte(originalView)); + Assert.Equal(first, originalCopy); + AssertOwned(appended, fallback); + + // An unchanged refresh must retain its existing view lifetime. + PageReadBuffer refreshedView = await provider.ReadPageAsync(1, ct); + provider.RefreshMapping(); + AssertMapped(appended, refreshedView); + + provider.Dispose(); + provider.Dispose(); + Assert.Throws(() => ReadFirstByte(refreshedView)); + AssertOwned(appended, fallback); + Assert.Equal(first, originalCopy); + + // Mapping disposal must leave the FileStorageDevice's handle open. + byte[] afterMappingDisposal = new byte[PageConstants.PageSize]; + Assert.Equal(PageConstants.PageSize, + await device.ReadAsync(PageConstants.PageSize, afterMappingDisposal, ct)); + Assert.Equal(appended, afterMappingDisposal); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task PartialLastPage_FallsBackToIndependentZeroPaddedOwnedBuffers() + { + var ct = TestContext.Current.CancellationToken; + string path = NewTempPath(); + byte[] first = PatternPage(29); + byte[] partial = PatternPage(107)[..137]; + byte[] expectedPartialPage = new byte[PageConstants.PageSize]; + partial.CopyTo(expectedPartialPage, 0); + + try + { + await using var device = new FileStorageDevice(path, createNew: true); + await device.WriteAsync(0, first, ct); + await device.WriteAsync(PageConstants.PageSize, partial, ct); + using var provider = new MemoryMappedPageReadProvider(device); + + AssertMapped(first, await provider.ReadPageAsync(0, ct)); + PageReadBuffer partialPage = await provider.ReadPageAsync(1, ct); + AssertOwned(expectedPartialPage, partialPage); + byte[] independentCopy = await provider.ReadOwnedPageAsync(1, ct); + Assert.Equal(expectedPartialPage, independentCopy); + Assert.True(partialPage.TryGetOwnedBuffer(out byte[]? owned)); + Assert.NotSame(owned, independentCopy); + + owned![0] ^= 0xff; + Assert.Equal(expectedPartialPage, independentCopy); + AssertOwned(expectedPartialPage, await provider.ReadPageAsync(1, ct)); + AssertOwned(new byte[PageConstants.PageSize], await provider.ReadPageAsync(2, ct)); + + provider.Dispose(); + Assert.Equal(expectedPartialPage, independentCopy); + Assert.Equal((byte)(expectedPartialPage[0] ^ 0xff), partialPage.Memory.Span[0]); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task ShrinkAfterReleasingMapping_RemapsRetainedPageAndZeroFillsRemovedPage() + { + var ct = TestContext.Current.CancellationToken; + string path = NewTempPath(); + byte[] retained = PatternPage(41); + byte[] removed = PatternPage(151); + + try + { + await using var device = new FileStorageDevice(path, createNew: true); + await device.WriteAsync(0, retained, ct); + await device.WriteAsync(PageConstants.PageSize, removed, ct); + PageReadBuffer previousView; + byte[] ownedCopy; + using (var original = new MemoryMappedPageReadProvider(device)) + { + previousView = await original.ReadPageAsync(1, ct); + AssertMapped(removed, previousView); + ownedCopy = await original.ReadOwnedPageAsync(1, ct); + } + + Assert.Throws(() => ReadFirstByte(previousView)); + // Releasing every mapped view before truncation is the supported + // cross-platform lifecycle. Do not access a live mapping after shrink. + await device.SetLengthAsync(PageConstants.PageSize, ct); + using (var replacement = new MemoryMappedPageReadProvider(device)) + { + Assert.Equal(PageConstants.PageSize, device.Length); + AssertMapped(retained, await replacement.ReadPageAsync(0, ct)); + AssertOwned(new byte[PageConstants.PageSize], await replacement.ReadPageAsync(1, ct)); + } + Assert.Equal(removed, ownedCopy); + + // Both providers used leaveOpen mappings; the owner still supports I/O. + byte[] changed = PatternPage(67); + await device.WriteAsync(0, changed, ct); + byte[] actual = new byte[PageConstants.PageSize]; + Assert.Equal(PageConstants.PageSize, await device.ReadAsync(0, actual, ct)); + Assert.Equal(changed, actual); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task GrowthPastIntMaxValue_DisablesMappingAndPreservesLongOffsetFallback() + { + var ct = TestContext.Current.CancellationToken; + string path = NewTempPath(); + byte[] first = PatternPage(53); + byte[] highPage = PatternPage(193); + const long highOffset = (long)int.MaxValue + 1; + const uint highPageId = (uint)(highOffset / PageConstants.PageSize); + + try + { + await using var device = new FileStorageDevice(path, createNew: true); + MakeTemporaryFileSparseOnWindows(path); + await device.WriteAsync(0, first, ct); + using var provider = new MemoryMappedPageReadProvider(device); + PageReadBuffer originalView = await provider.ReadPageAsync(0, ct); + AssertMapped(first, originalView); + + // Only two pages are written. The intervening range is a hole; no + // 2 GiB buffer, write loop, or mapped view is created by this test. + await device.WriteAsync(highOffset, highPage, ct); + Assert.Equal(highOffset + PageConstants.PageSize, device.Length); + AssertMapped(first, await provider.ReadPageAsync(0, ct)); + PageReadBuffer beforeRefresh = await provider.ReadPageAsync(highPageId, ct); + AssertOwned(highPage, beforeRefresh); + + provider.RefreshMapping(); + + Assert.Throws(() => ReadFirstByte(originalView)); + AssertOwned(first, await provider.ReadPageAsync(0, ct)); + AssertOwned(highPage, await provider.ReadPageAsync(highPageId, ct)); + AssertOwned(new byte[PageConstants.PageSize], await provider.ReadPageAsync(highPageId - 1, ct)); + byte[] independentCopy = await provider.ReadOwnedPageAsync(highPageId, ct); + Assert.Equal(highPage, independentCopy); + Assert.True(beforeRefresh.TryGetOwnedBuffer(out byte[]? owned)); + Assert.NotSame(owned, independentCopy); + owned![0] ^= 0xff; + Assert.Equal(highPage, independentCopy); + AssertOwned(highPage, await provider.ReadPageAsync(highPageId, ct)); + + provider.Dispose(); + Assert.Equal(highPage, independentCopy); + byte[] actual = new byte[PageConstants.PageSize]; + Assert.Equal(PageConstants.PageSize, await device.ReadAsync(highOffset, actual, ct)); + Assert.Equal(highPage, actual); + } + finally + { + File.Delete(path); + } + } + + private static void AssertMapped(byte[] expected, PageReadBuffer buffer) + { + Assert.False(buffer.TryGetOwnedBuffer(out _)); + Assert.Equal(expected, buffer.Memory.ToArray()); + } + + private static void AssertOwned(byte[] expected, PageReadBuffer buffer) + { + Assert.True(buffer.TryGetOwnedBuffer(out byte[]? owned)); + Assert.Equal(expected, owned); + Assert.Equal(expected, buffer.Memory.ToArray()); + Assert.Same(owned, buffer.MaterializeOwnedBuffer()); + } + + private static byte ReadFirstByte(PageReadBuffer buffer) => buffer.Memory.Span[0]; + + private static byte[] PatternPage(int seed) + { + byte[] page = new byte[PageConstants.PageSize]; + for (int i = 0; i < page.Length; i++) + page[i] = (byte)((i * 131 + seed) & 0xff); + return page; + } + + private static string NewTempPath() => + Path.Combine(Path.GetTempPath(), $"csharpdb_mmap_boundary_{Guid.NewGuid():N}.db"); + + private static void MakeTemporaryFileSparseOnWindows(string path) + { + if (!OperatingSystem.IsWindows()) + return; + + // This marks only this throwaway file sparse; it changes no OS settings. + // Use a synchronous handle for this synchronous metadata operation. + using SafeFileHandle handle = File.OpenHandle(path, FileMode.Open, + FileAccess.ReadWrite, FileShare.ReadWrite, FileOptions.None); + if (DeviceIoControl(handle, 0x000900C4 /* FSCTL_SET_SPARSE */, + IntPtr.Zero, 0, IntPtr.Zero, 0, out _, IntPtr.Zero)) + return; + + int error = Marshal.GetLastWin32Error(); + if (error is 1 or 50) // ERROR_INVALID_FUNCTION / ERROR_NOT_SUPPORTED + Assert.Skip("The temporary volume does not support sparse files; refusing to allocate a 2 GiB test file."); + throw new Win32Exception(error, "Could not mark the temporary boundary-test file sparse."); + } + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DeviceIoControl(SafeFileHandle handle, uint controlCode, + IntPtr input, uint inputLength, IntPtr output, uint outputLength, + out uint bytesReturned, IntPtr overlapped); +} diff --git a/tests/CSharpDB.Tests/MemoryMappedSnapshotTests.cs b/tests/CSharpDB.Tests/MemoryMappedSnapshotTests.cs new file mode 100644 index 00000000..41df0e0d --- /dev/null +++ b/tests/CSharpDB.Tests/MemoryMappedSnapshotTests.cs @@ -0,0 +1,306 @@ +using System.Reflection; +using CSharpDB.Engine; +using CSharpDB.Execution; +using CSharpDB.Primitives; +using CSharpDB.Storage.Catalog; +using CSharpDB.Storage.Checkpointing; +using CSharpDB.Storage.Paging; +using CSharpDB.Storage.StorageEngine; +using CSharpDB.Storage.Wal; + +namespace CSharpDB.Tests; + +public sealed class MemoryMappedSnapshotTests +{ + [Theory(Timeout = 30_000)] + [InlineData(false)] + [InlineData(true)] + public async Task BaseFileSnapshot_ContinuesScanAcrossWriterGrowthAndDeferredCheckpoints(bool mapped) + { + CancellationToken ct = TestContext.Current.CancellationToken; + string path = CreatePath(); + List retained = []; + try + { + await using (var db = await Database.OpenAsync(path, CreateOptions(mapped), ct)) + { + await SeedAsync(db, "items", 64, ct); + await db.CheckpointAsync(ct); + uint root = GetTableRoot(db, "items"); + IWriteAheadLog wal = GetWal(db); + + using (var reader = db.CreateReaderSession()) + { + Assert.False(GetField(reader, "_snapshot").HasWalFrames); + // No ORDER BY: keep a streaming table scan open instead of a materialized sort. + await using (var scan = await reader.ExecuteReadAsync("SELECT id, label, payload FROM items", ct)) + { + Assert.True(await scan.MoveNextAsync(ct)); + retained.Add(scan.Current.ToArray()); + AssertBasePageRead(reader, root, mapped); + + await db.BeginTransactionAsync(ct); + await UpdateAsync(db, "items", 64, version: 1, ct); + await InsertAsync(db, "items", start: 64, count: 96, version: 1, ct); + await db.CommitAsync(ct); + await db.CheckpointAsync(ct); + await db.CheckpointAsync(ct); + + Assert.True(wal.HasPendingCheckpoint); + Assert.False(wal.IsCheckpointCopyComplete); + await ReadRemainingAsync(scan, retained, ct); + AssertRows(retained, count: 64, version: 0); + } + + // A fresh query on the same session also keeps the original page chain. + AssertRows(await ReadAllAsync(reader, "items", ct), count: 64, version: 0); + } + + await db.CheckpointAsync(ct); + Assert.False(wal.HasPendingCheckpoint); + using var latest = db.CreateReaderSession(); + AssertRows(await ReadAllAsync(latest, "items", ct), count: 160, version: 1); + } + + // Only the DbValue row wrappers were copied; text/blob values must own their data. + AssertRows(retained, count: 64, version: 0); + } + finally { DeleteFiles(path); } + } + + [Theory(Timeout = 30_000)] + [InlineData(false)] + [InlineData(true)] + public async Task WalCoveredSnapshot_PreservesWalRowsAndMappedBasePagesWhileCheckpointCopies(bool mapped) + { + CancellationToken ct = TestContext.Current.CancellationToken; + string path = CreatePath(); + List retained = []; + DbValue[] retainedBaseRow = []; + try + { + await using (var db = await Database.OpenAsync(path, CreateOptions(mapped), ct)) + { + await SeedAsync(db, "base_rows", 64, ct); + await SeedAsync(db, "versioned", 8, ct); + await db.CheckpointAsync(ct); + uint baseRoot = GetTableRoot(db, "base_rows"); + uint changedRoot = GetTableRoot(db, "versioned"); + await db.BeginTransactionAsync(ct); + await UpdateAsync(db, "versioned", 8, version: 1, ct); + await db.CommitAsync(ct); + IWriteAheadLog wal = GetWal(db); + + using (var reader = db.CreateReaderSession()) + { + WalSnapshot snapshot = GetField(reader, "_snapshot"); + Assert.True(snapshot.HasWalFrames); + Assert.True(snapshot.TryGet(changedRoot, out _)); + Assert.False(snapshot.TryGet(baseRoot, out _)); + + // Materialize a base-file page before checkpointing even though the changed + // table's snapshot versions are in the WAL. + await using (var baseScan = await reader.ExecuteReadAsync("SELECT id, label, payload FROM base_rows", ct)) + { + Assert.True(await baseScan.MoveNextAsync(ct)); + retainedBaseRow = baseScan.Current.ToArray(); + AssertBasePageRead(reader, baseRoot, mapped); + } + + await using (var scan = await reader.ExecuteReadAsync("SELECT id, label, payload FROM versioned", ct)) + { + Assert.True(await scan.MoveNextAsync(ct)); + retained.Add(scan.Current.ToArray()); + await db.BeginTransactionAsync(ct); + await UpdateAsync(db, "versioned", 8, version: 2, ct); + await db.CommitAsync(ct); + await db.CheckpointAsync(ct); + await db.CheckpointAsync(ct); + + Assert.True(wal.HasPendingCheckpoint); + Assert.True(wal.IsCheckpointCopyComplete); + await ReadRemainingAsync(scan, retained, ct); + AssertRows(retained, count: 8, version: 1); + } + + AssertRows(await ReadAllAsync(reader, "base_rows", ct), count: 64, version: 0); + AssertRows(await ReadAllAsync(reader, "versioned", ct), count: 8, version: 1); + } + + await db.CheckpointAsync(ct); + Assert.False(wal.HasPendingCheckpoint); + using var latest = db.CreateReaderSession(); + AssertRows(await ReadAllAsync(latest, "versioned", ct), count: 8, version: 2); + } + + AssertRows(retained, count: 8, version: 1); + AssertRow(retainedBaseRow, expectedId: 0, version: 0); + } + finally { DeleteFiles(path); } + } + + [Theory(Timeout = 30_000)] + [InlineData(false)] + [InlineData(true)] + public async Task DisposingOneSnapshot_DoesNotInvalidateAnotherReadersBaseFileScanOrOwnedValues(bool mapped) + { + CancellationToken ct = TestContext.Current.CancellationToken; + string path = CreatePath(); + DbValue[] retainedFirstRow = []; + List retainedSecondRows = []; + try + { + await using (var db = await Database.OpenAsync(path, CreateOptions(mapped), ct)) + { + await SeedAsync(db, "items", 64, ct); + await db.CheckpointAsync(ct); + uint root = GetTableRoot(db, "items"); + IWriteAheadLog wal = GetWal(db); + using (var first = db.CreateReaderSession()) + using (var second = db.CreateReaderSession()) + { + await using (var firstScan = await first.ExecuteReadAsync("SELECT id, label, payload FROM items", ct)) + { + Assert.True(await firstScan.MoveNextAsync(ct)); + retainedFirstRow = firstScan.Current.ToArray(); + AssertBasePageRead(first, root, mapped); + } + + await using (var secondScan = await second.ExecuteReadAsync("SELECT id, label, payload FROM items", ct)) + { + Assert.True(await secondScan.MoveNextAsync(ct)); + retainedSecondRows.Add(secondScan.Current.ToArray()); + AssertBasePageRead(second, root, mapped); + + await db.BeginTransactionAsync(ct); + await UpdateAsync(db, "items", 64, version: 1, ct); + await InsertAsync(db, "items", start: 64, count: 32, version: 1, ct); + await db.CommitAsync(ct); + await db.CheckpointAsync(ct); + first.Dispose(); + await db.CheckpointAsync(ct); + + Assert.True(wal.HasPendingCheckpoint); + Assert.False(wal.IsCheckpointCopyComplete); + AssertRow(retainedFirstRow, expectedId: 0, version: 0); + await ReadRemainingAsync(secondScan, retainedSecondRows, ct); + AssertRows(retainedSecondRows, count: 64, version: 0); + } + } + + await db.CheckpointAsync(ct); + Assert.False(wal.HasPendingCheckpoint); + using var latest = db.CreateReaderSession(); + AssertRows(await ReadAllAsync(latest, "items", ct), count: 96, version: 1); + } + + AssertRow(retainedFirstRow, expectedId: 0, version: 0); + AssertRows(retainedSecondRows, count: 64, version: 0); + } + finally { DeleteFiles(path); } + } + + private static DatabaseOptions CreateOptions(bool mapped) => new() + { + StorageEngineOptions = new StorageEngineOptions + { + PagerOptions = new PagerOptions + { + UseMemoryMappedReads = mapped, + // Explicit checkpoints control the interleaving; all cache settings stay at defaults. + CheckpointPolicy = new FrameCountCheckpointPolicy(10_000), + }, + }, + }; + + private static async Task SeedAsync(Database db, string table, int count, CancellationToken ct) + { + await ExecuteAsync(db, $"CREATE TABLE {table} (id INTEGER PRIMARY KEY, label TEXT, payload BLOB)", ct); + await db.BeginTransactionAsync(ct); + await InsertAsync(db, table, start: 0, count, version: 0, ct); + await db.CommitAsync(ct); + } + + private static async Task InsertAsync(Database db, string table, int start, int count, int version, CancellationToken ct) + { + for (int id = start; id < start + count; id++) + await ExecuteAsync(db, $"INSERT INTO {table} VALUES ({id}, '{Text(id, version)}', X'{Convert.ToHexString(Blob(id, version))}')", ct); + } + + private static async Task UpdateAsync(Database db, string table, int count, int version, CancellationToken ct) + { + for (int id = 0; id < count; id++) + await ExecuteAsync(db, $"UPDATE {table} SET label = '{Text(id, version)}', payload = X'{Convert.ToHexString(Blob(id, version))}' WHERE id = {id}", ct); + } + + private static async Task ExecuteAsync(Database db, string sql, CancellationToken ct) + { + await using var result = await db.ExecuteAsync(sql, ct); + } + + private static async Task> ReadAllAsync(Database.ReaderSession reader, string table, CancellationToken ct) + { + await using var result = await reader.ExecuteReadAsync($"SELECT id, label, payload FROM {table}", ct); + List rows = []; + await ReadRemainingAsync(result, rows, ct); + return rows; + } + + private static async Task ReadRemainingAsync(QueryResult result, List rows, CancellationToken ct) + { + while (await result.MoveNextAsync(ct)) + rows.Add(result.Current.ToArray()); + } + + private static void AssertRows(List rows, int count, int version) + { + Assert.Equal(count, rows.Count); + DbValue[][] ordered = rows.OrderBy(row => row[0].AsInteger).ToArray(); + for (int id = 0; id < count; id++) + AssertRow(ordered[id], id, version); + } + + private static void AssertRow(DbValue[] row, int expectedId, int version) + { + Assert.Equal(3, row.Length); + Assert.Equal((long)expectedId, row[0].AsInteger); + Assert.Equal(Text(expectedId, version), row[1].AsText); + Assert.Equal(Blob(expectedId, version), row[2].AsBlob); + } + + private static void AssertBasePageRead(Database.ReaderSession reader, uint root, bool mapped) + { + Pager pager = GetField(reader, "_snapshotPager"); + IPageReadProvider provider = GetField(pager, "_pageReads"); + if (mapped) + Assert.IsType(provider); + else + Assert.IsType(provider); + Assert.True(pager.TryGetCachedPageReadBuffer(root, out PageReadBuffer page)); + Assert.Equal(!mapped, page.TryGetOwnedBuffer(out _)); + } + + private static uint GetTableRoot(Database db, string table) + => GetField(db, "_catalog").GetTableRootPage(table); + + private static IWriteAheadLog GetWal(Database db) + => GetField(GetField(db, "_pager"), "_wal"); + + private static T GetField(object instance, string name) + => Assert.IsAssignableFrom(instance.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(instance)); + + private static string Text(int id, int version) + => $"v{version}-row-{id:D3}-" + new string((char)('a' + id % 26), 192); + + private static byte[] Blob(int id, int version) + => Enumerable.Range(0, 96).Select(index => (byte)((id * 17 + version * 31 + index) & 255)).ToArray(); + + private static string CreatePath() + => Path.Combine(Path.GetTempPath(), $"csharpdb_mapped_snapshot_{Guid.NewGuid():N}.db"); + + private static void DeleteFiles(string path) + { + foreach (string file in new[] { path, path + ".wal", path + ".lock" }) + if (File.Exists(file)) File.Delete(file); + } +} diff --git a/tests/CSharpDB.Tests/OrdinaryPrimaryKeyRangeTests.cs b/tests/CSharpDB.Tests/OrdinaryPrimaryKeyRangeTests.cs new file mode 100644 index 00000000..f30e55e2 --- /dev/null +++ b/tests/CSharpDB.Tests/OrdinaryPrimaryKeyRangeTests.cs @@ -0,0 +1,244 @@ +using CSharpDB.Engine; +using CSharpDB.Execution; +using CSharpDB.Primitives; + +namespace CSharpDB.Tests; + +public sealed class OrdinaryPrimaryKeyRangeTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Theory] + [InlineData("100 <= x.id AND x.id < 104", "100,101,102,103")] + [InlineData("x.id BETWEEN 100 AND 103", "100,101,102,103")] + [InlineData("x.id >= 98 AND x.id >= 100 AND x.id <= 105 AND x.id < 104", "100,101,102,103")] + [InlineData("x.id >= 252", "252,253,254,255")] + [InlineData("x.id < 3", "0,1,2")] + [InlineData("x.id >= 104 AND x.id < 100", "")] + public async Task ArithmeticProjection_UsesBoundedOrdinaryRange(string predicate, string expected) + { + await using var db = await CreateAsync(); + long[] ids = ParseIds(expected); + List rows = await ReadAsync(db, + $"SELECT x.id + 1 AS next_id, x.value * 2 AS twice_value FROM t x WHERE {predicate} ORDER BY x.id LIMIT 4", + requireRange: ids.Length > 0); + Assert.Equal(ids.Select(id => id + 1), rows.Select(row => row[0].AsInteger)); + Assert.Equal(ids.Select(id => id * 20), rows.Select(row => row[1].AsInteger)); + } + + [Theory] + [InlineData("x.id", "104,105")] + [InlineData("x.id DESC", "105,104")] + [InlineData("x.value", "105,104")] + public async Task ResidualPredicate_IsAppliedBeforeOrderingOffsetAndLimit(string order, string expected) + { + await using var db = await CreateAsync(); + await db.ExecuteAsync("UPDATE t SET value = 1000 - id WHERE id >= 100 AND id < 107", Ct); + List rows = await ReadAsync(db, + "SELECT x.id + 1 AS next_id FROM t x " + + $"WHERE x.id >= 100 AND x.id < 107 AND x.value < 898 ORDER BY {order} LIMIT 2 OFFSET 1", + requireRange: true); + Assert.Equal(ParseIds(expected).Select(id => id + 1), rows.Select(row => row[0].AsInteger)); + } + + [Theory] + [InlineData("(x.id >= 100 AND x.id < 104) OR x.id = 1", "1,100,101,102")] + [InlineData("x.id >= 100 AND x.id < 106 AND (x.value = 1010 OR x.value = 1040)", "101,104")] + [InlineData("x.id >= NULL AND x.id < 7", "")] + public async Task UnconsumedPredicates_PreserveOrAndNullSemantics(string predicate, string expected) + { + await using var db = await CreateAsync(); + List rows = await ReadAsync(db, + $"SELECT x.id + 1 FROM t x WHERE {predicate} ORDER BY x.id LIMIT 4"); + Assert.Equal(ParseIds(expected).Select(id => id + 1), rows.Select(row => row[0].AsInteger)); + } + + [Theory] + [InlineData("x.id >= 2.5 AND x.id < 7")] + [InlineData("x.id > 2 AND x.id < 4.5")] + public async Task FractionalBounds_PreserveExactIntegerCoercionErrors(string predicate) + { + await using var db = await CreateAsync(); + CSharpDbException error = await Assert.ThrowsAsync(async () => + await ReadAsync(db, $"SELECT x.id + 1 FROM t x WHERE {predicate} ORDER BY x.id LIMIT 4")); + Assert.Equal(ErrorCode.TypeMismatch, error.Code); + Assert.Equal( + "Value of type Real is not valid for column '' declared as INTEGER: An exact integral value is required.", + error.Message); + } + + [Theory] + [InlineData("SMALLINT", false)] + [InlineData("INTEGER", true)] + public async Task BackedPrimaryKeys_DoNotUsePhysicalRowIds(string type, bool composite) + { + await using var db = await Database.OpenInMemoryAsync(Ct); + await db.ExecuteAsync(composite + ? $"CREATE TABLE t (id {type}, value INTEGER, PRIMARY KEY (id, value))" + : $"CREATE TABLE t (id {type} PRIMARY KEY, value INTEGER)", Ct); + await db.ExecuteAsync(composite + ? "INSERT INTO t VALUES (11, 100), (11, 200), (22, 300)" + : "INSERT INTO t VALUES (11, 100), (22, 300)", Ct); + await using QueryResult result = await db.ExecuteAsync( + "SELECT id + 1, value FROM t WHERE id >= 10 AND id < 20 ORDER BY value", Ct); + Assert.DoesNotContain(Operators(result.PhysicalRootOperator), op => op is PrimaryKeyRangeScanOperator); + List rows = await result.ToListAsync(Ct); + Assert.Equal(composite ? new long[] { 12, 12 } : [12L], rows.Select(row => row[0].AsInteger)); + Assert.Equal(composite ? new long[] { 100, 200 } : [100L], rows.Select(row => row[1].AsInteger)); + } + + [Fact] + public async Task ExtremeExclusiveBounds_DoNotOverflowOrIncludeEndpoints() + { + await using var db = await Database.OpenInMemoryAsync(Ct); + await db.ExecuteAsync("CREATE TABLE t (id BIGINT PRIMARY KEY, value INTEGER)", Ct); + await db.ExecuteAsync($"INSERT INTO t VALUES ({long.MinValue}, 1), (-1, 2), (0, 3), ({long.MaxValue}, 4)", Ct); + List rows = await ReadAsync(db, + $"SELECT id, value + 1 FROM t WHERE id > {long.MinValue} AND id < {long.MaxValue} ORDER BY id"); + Assert.Equal(new long[] { -1, 0 }, rows.Select(row => row[0].AsInteger)); + Assert.Empty(await ReadAsync(db, $"SELECT id, value + 1 FROM t WHERE id > {long.MaxValue} ORDER BY id")); + Assert.Empty(await ReadAsync(db, $"SELECT id, value + 1 FROM t WHERE id < {long.MinValue} ORDER BY id")); + rows = await ReadAsync(db, $"SELECT id, value + 1 FROM t WHERE id >= {long.MaxValue} ORDER BY id"); + Assert.Equal(long.MaxValue, Assert.Single(rows)[0].AsInteger); + } + + [Fact] + public async Task JoinLocalRanges_PreserveAliasesDuplicateMatchesAndResidualPredicates() + { + await using var db = await CreateAsync(); + await db.ExecuteAsync("CREATE TABLE u (id INTEGER PRIMARY KEY, owner INTEGER, payload INTEGER)", Ct); + await db.ExecuteAsync("INSERT INTO u VALUES (1, 100, 10), (2, 100, 20), (3, 101, 30), (4, 102, 40), (5, 103, 50)", Ct); + List rows = await ReadAsync(db, + "SELECT l.id + 1, r.payload * 2 FROM t l JOIN u r ON l.id = r.owner " + + "WHERE 100 <= l.id AND l.id < 103 AND r.id >= 1 AND r.id < 4 AND r.payload < 40 ORDER BY l.id, r.id", + requireRange: true); + Assert.Equal(new long[] { 101, 101, 102 }, rows.Select(row => row[0].AsInteger)); + Assert.Equal(new long[] { 20, 40, 60 }, rows.Select(row => row[1].AsInteger)); + + rows = await ReadAsync(db, + "SELECT l.id + 1, r.payload * 2 FROM t l LEFT JOIN u r ON l.id = r.owner " + + "WHERE l.id >= 103 AND l.id < 105 ORDER BY l.id", requireRange: true); + Assert.Equal(new long[] { 104, 105 }, rows.Select(row => row[0].AsInteger)); + Assert.Equal(100, rows[0][1].AsInteger); + Assert.True(rows[1][1].IsNull); + } + + [Fact] + public async Task SameNamedCte_UsesMaterializedRowsInsteadOfPersistentTableRange() + { + await using var db = await CreateAsync(); + await db.ExecuteAsync("CREATE TABLE u (id INTEGER PRIMARY KEY, value BIGINT)", Ct); + await db.ExecuteAsync("INSERT INTO u VALUES (1000, 7), (1001, 8)", Ct); + await using QueryResult result = await db.ExecuteAsync( + "WITH t AS (SELECT id, value FROM u) " + + "SELECT id + 1, value * 2 FROM t WHERE id >= 1000 AND id < 1002 ORDER BY id", Ct); + Assert.DoesNotContain(Operators(result.PhysicalRootOperator), op => op is PrimaryKeyRangeScanOperator); + List rows = await result.ToListAsync(Ct); + Assert.Equal(new long[] { 1001, 1002 }, rows.Select(row => row[0].AsInteger)); + Assert.Equal(new long[] { 14, 16 }, rows.Select(row => row[1].AsInteger)); + } + + [Fact] + public async Task NullableCustomFunctionResidual_FiltersBeforeSmallLimit() + { + DatabaseOptions options = new DatabaseOptions().ConfigureFunctions(functions => functions.AddScalar( + "KeepValue", 1, + new DbScalarFunctionOptions(DbType.Integer, IsDeterministic: true, NullPropagating: true), + static (_, args) => DbValue.FromInteger(args[0].AsInteger >= 40 ? 1 : 0))); + await using var db = await Database.OpenInMemoryAsync(options, Ct); + await db.ExecuteAsync("CREATE TABLE t (id INTEGER PRIMARY KEY, value BIGINT)", Ct); + await db.ExecuteAsync("INSERT INTO t VALUES (100, NULL), (101, 10), (102, 20), (103, NULL), (104, 40), (105, 50)", Ct); + List rows = await ReadAsync(db, + "SELECT id + 1, value * 2 FROM t WHERE id >= 100 AND id < 106 " + + "AND value IS NOT NULL AND KeepValue(value) = 1 ORDER BY id LIMIT 1", requireRange: true); + Assert.Equal(new long[] { 105, 80 }, Assert.Single(rows).Select(value => value.AsInteger)); + } + + [Theory] + [InlineData(12, true)] + [InlineData(20, false)] + public async Task OrdinarySerializableRange_TracksIncludedKeysAndExcludesUpperEndpoint(int insertedKey, bool conflicts) + { + await using var db = await Database.OpenInMemoryAsync(Ct); + await db.ExecuteAsync("CREATE TABLE t (id INTEGER PRIMARY KEY, value BIGINT)", Ct); + await db.ExecuteAsync("INSERT INTO t VALUES (15, 150), (30, 300)", Ct); + await using var transaction = await db.BeginWriteTransactionAsync(Ct); + await using (QueryResult result = await transaction.ExecuteAsync( + "SELECT id + 1, value * 2 FROM t WHERE id >= 10 AND id < 20 ORDER BY id", Ct)) + { + Assert.Contains(Operators(result.PhysicalRootOperator), op => op is PrimaryKeyRangeScanOperator); + Assert.Equal(new long[] { 16, 300 }, Assert.Single(await result.ToListAsync(Ct)).Select(value => value.AsInteger)); + } + await db.ExecuteAsync($"INSERT INTO t VALUES ({insertedKey}, 1)", Ct); + if (conflicts) + await Assert.ThrowsAsync(() => transaction.CommitAsync(Ct).AsTask()); + else + await transaction.CommitAsync(Ct); + } + + [Fact] + public async Task SnapshotRange_AfterRootGrowthAndTableReplacement_KeepsOriginalSchemaAndRows() + { + await using var db = await CreateAsync(); + using var reader = db.CreateReaderSession(); + const string sql = "SELECT id + 1, value * 2 FROM t WHERE id >= 250 AND id < 260 ORDER BY id"; + await using (QueryResult warm = await reader.ExecuteReadAsync(sql, Ct)) + Assert.Equal(6, (await warm.ToListAsync(Ct)).Count); + await InsertAsync(db, 256, 1024); + await db.ExecuteAsync("DROP TABLE t", Ct); + await db.ExecuteAsync("CREATE TABLE t (value BIGINT, id INTEGER PRIMARY KEY)", Ct); + await db.ExecuteAsync("INSERT INTO t VALUES (9, 250)", Ct); + + await using (QueryResult old = await reader.ExecuteReadAsync(sql, Ct)) + { + Assert.Contains(Operators(old.PhysicalRootOperator), op => op is PrimaryKeyRangeScanOperator); + List rows = await old.ToListAsync(Ct); + Assert.Equal(Enumerable.Range(251, 6).Select(i => (long)i), rows.Select(row => row[0].AsInteger)); + Assert.Equal(Enumerable.Range(250, 6).Select(i => i * 20L), rows.Select(row => row[1].AsInteger)); + } + List latest = await ReadAsync(db, sql, requireRange: true); + Assert.Equal(new long[] { 251, 18 }, Assert.Single(latest).Select(value => value.AsInteger)); + } + + private static async Task CreateAsync() + { + Database db = await Database.OpenInMemoryAsync(Ct); + await db.ExecuteAsync("CREATE TABLE t (id INTEGER PRIMARY KEY, value BIGINT)", Ct); + await InsertAsync(db, 0, 256); + return db; + } + + private static async Task InsertAsync(Database db, int start, int count) + { + await db.BeginTransactionAsync(Ct); + var batch = db.PrepareInsertBatch("t", count); + for (int id = start; id < start + count; id++) + batch.AddRow([DbValue.FromInteger(id), DbValue.FromInteger(id * 10L)]); + await batch.ExecuteAsync(Ct); + await db.CommitAsync(Ct); + } + + private static async Task> ReadAsync(Database db, string sql, bool requireRange = false) + { + await using QueryResult result = await db.ExecuteAsync(sql, Ct); + if (requireRange) + Assert.Contains(Operators(result.PhysicalRootOperator), op => op is PrimaryKeyRangeScanOperator); + return await result.ToListAsync(Ct); + } + + private static IEnumerable Operators(IOperator? op) + { + if (op is null) yield break; + yield return op; + if (op is IPhysicalOperatorChildren children) + foreach (IOperator child in children.PhysicalChildren) + foreach (IOperator descendant in Operators(child)) yield return descendant; + else if (op is IUnaryOperatorSource unary) + foreach (IOperator descendant in Operators(unary.Source)) yield return descendant; + else if (op is BatchToRowOperatorAdapter adapter && adapter.BatchSource is IOperator source) + foreach (IOperator descendant in Operators(source)) yield return descendant; + } + + private static long[] ParseIds(string value) + => value.Length == 0 ? [] : value.Split(',').Select(long.Parse).ToArray(); +} diff --git a/tests/CSharpDB.Tests/PagerLegacyCatalogCaptureTests.cs b/tests/CSharpDB.Tests/PagerLegacyCatalogCaptureTests.cs new file mode 100644 index 00000000..07b642a6 --- /dev/null +++ b/tests/CSharpDB.Tests/PagerLegacyCatalogCaptureTests.cs @@ -0,0 +1,113 @@ +using CSharpDB.Storage.Checkpointing; +using CSharpDB.Storage.Device; +using CSharpDB.Storage.Paging; +using CSharpDB.Storage.Transactions; +using CSharpDB.Storage.Wal; + +namespace CSharpDB.Tests; + +public class PagerLegacyCatalogCaptureTests +{ + [Theory(Timeout = 30_000)] + [InlineData(false, 0)] + [InlineData(true, 0)] + [InlineData(false, 1)] + [InlineData(true, 1)] + [InlineData(false, 2)] + [InlineData(true, 2)] + public async Task CaptureWaitsForWholeLegacyCompletion_AndFailsClosedAfterInterceptorFailure( + bool reservePendingCommit, int completionFailure) + { + CancellationToken ct = TestContext.Current.CancellationToken; + var interceptor = new CompletionInterceptor(completionFailure); + await using var device = new MemoryStorageDevice(); + var index = new WalIndex(); + await using var wal = new MemoryWriteAheadLog(index); + await using var pager = await Pager.CreateAsync(device, wal, index, new PagerOptions + { + CheckpointPolicy = new FrameCountCheckpointPolicy(10_000), + Interceptors = [interceptor], + }, ct); + await pager.InitializeNewDatabaseAsync(ct); + await pager.RecoverAsync(ct); + await using PagerWriteTransaction? otherWriter = reservePendingCommit + ? await pager.BeginWriteTransactionAsync(ct) + : null; + WalSnapshot beforeCommit = pager.AcquireReaderSnapshot(); + WalSnapshot? published = null; + try + { + Assert.True(pager.CanCaptureCommittedCatalog(beforeCommit)); + await pager.BeginTransactionAsync(ct); + Assert.False(pager.CanCaptureCommittedCatalog(beforeCommit)); + PagerCommitResult commit = await pager.BeginCommitAsync(ct); + await interceptor.Started.Task.WaitAsync(ct); + published = pager.AcquireReaderSnapshot(); + + // WAL publication and writer release have already completed; the + // interceptor is the remaining part of legacy commit completion. + Assert.False(pager.IsWriteTransactionActive); + Assert.False(wal.HasPendingCommitWork); + Assert.False(pager.CanCaptureCommittedCatalog(published)); + interceptor.Release.TrySetResult(); + if (completionFailure == 1) + await Assert.ThrowsAsync(() => commit.WaitAsync(ct).AsTask()); + else if (completionFailure == 2) + await Assert.ThrowsAnyAsync(() => commit.WaitAsync(ct).AsTask()); + else + await commit.WaitAsync(ct); + + Assert.False(pager.CanCaptureCommittedCatalog(beforeCommit)); + Assert.Equal(completionFailure == 0, pager.CanCaptureCommittedCatalog(published)); + + // A later successful commit must not clear the conservative failure + // state, and a completed guard must not leak a pending count. + await pager.BeginTransactionAsync(ct); + await pager.CommitAsync(ct); + WalSnapshot next = pager.AcquireReaderSnapshot(); + try + { + Assert.Equal(completionFailure == 0, pager.CanCaptureCommittedCatalog(next)); + Assert.False(pager.CanCaptureCommittedCatalog(published)); + } + finally { pager.ReleaseReaderSnapshot(next); } + } + finally + { + interceptor.Release.TrySetResult(); + if (published is not null) + pager.ReleaseReaderSnapshot(published); + pager.ReleaseReaderSnapshot(beforeCommit); + } + } + + private sealed class CompletionInterceptor(int completionFailure) : IPageOperationInterceptor + { + private bool _armed = true; + public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public async ValueTask OnCommitEndAsync(int dirtyPageCount, bool succeeded, CancellationToken ct = default) + { + if (!_armed) + return; + _armed = false; + Started.TrySetResult(); + await Release.Task.WaitAsync(ct); + if (completionFailure == 1) + throw new InvalidOperationException("Injected commit completion failure."); + if (completionFailure == 2) + throw new OperationCanceledException("Injected commit completion cancellation."); + } + + public ValueTask OnBeforeReadAsync(uint pageId, CancellationToken ct = default) => ValueTask.CompletedTask; + public ValueTask OnAfterReadAsync(uint pageId, PageReadSource source, CancellationToken ct = default) => ValueTask.CompletedTask; + public ValueTask OnBeforeWriteAsync(uint pageId, CancellationToken ct = default) => ValueTask.CompletedTask; + public ValueTask OnAfterWriteAsync(uint pageId, bool succeeded, CancellationToken ct = default) => ValueTask.CompletedTask; + public ValueTask OnCommitStartAsync(int dirtyPageCount, CancellationToken ct = default) => ValueTask.CompletedTask; + public ValueTask OnCheckpointStartAsync(int committedFrameCount, CancellationToken ct = default) => ValueTask.CompletedTask; + public ValueTask OnCheckpointEndAsync(int committedFrameCount, bool succeeded, CancellationToken ct = default) => ValueTask.CompletedTask; + public ValueTask OnRecoveryStartAsync(CancellationToken ct = default) => ValueTask.CompletedTask; + public ValueTask OnRecoveryEndAsync(bool succeeded, CancellationToken ct = default) => ValueTask.CompletedTask; + } +} diff --git a/tests/CSharpDB.Tests/PreparedPrimaryKeyRangeTests.cs b/tests/CSharpDB.Tests/PreparedPrimaryKeyRangeTests.cs new file mode 100644 index 00000000..8488fb0a --- /dev/null +++ b/tests/CSharpDB.Tests/PreparedPrimaryKeyRangeTests.cs @@ -0,0 +1,261 @@ +using CSharpDB.Engine; +using CSharpDB.Execution; +using CSharpDB.Primitives; +using CSharpDB.Sql; + +namespace CSharpDB.Tests; + +public sealed class PreparedPrimaryKeyRangeTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + private static PreparedSelectTemplate Prepare(string sql, params string[] names) + => Assert.IsType(PreparedSelectTemplate.TryCreate(Parser.Parse(sql), names)); + + private static async Task CreateAsync(int count = 40) + { + var db = await Database.OpenInMemoryAsync(Ct); + await db.ExecuteAsync("CREATE TABLE t (id BIGINT PRIMARY KEY, value BIGINT)", Ct); + await InsertAsync(db, 0, count); + db.ResetSelectPlanCacheDiagnostics(); + return db; + } + + private static async Task InsertAsync(Database db, int start, int count) + { + if (count == 0) return; + await db.BeginTransactionAsync(Ct); + var batch = db.PrepareInsertBatch("t", count); + for (int i = start; i < start + count; i++) + batch.AddRow([DbValue.FromInteger(i), DbValue.FromInteger(i * 10L)]); + await batch.ExecuteAsync(Ct); + await db.CommitAsync(Ct); + } + + private static async Task ReadAsync(Database db, Statement statement) + { + await using var result = await db.ExecuteAsync(statement, Ct); + return (await result.ToListAsync(Ct)).Select(row => row[0].AsInteger).ToArray(); + } + + [Theory] + [InlineData(1)] + [InlineData(65)] + [InlineData(2048)] + public async Task ChangingRangeBounds_ReusesOnePlan(int cardinality) + { + await using var db = await CreateAsync(cardinality + 2); + var template = Prepare("SELECT * FROM t WHERE id >= @lower AND id < @upper ORDER BY id", "lower", "upper"); + int executions = cardinality * 2 + 3; + for (int i = 0; i < executions; i++) + { + long lower = i % cardinality; + await using var result = await db.ExecuteAsync(template.Bind([lower, lower + 3]), Ct); + if (i == 0) Assert.IsType(result.PhysicalRootOperator); + var rows = await result.ToListAsync(Ct); + Assert.Equal(new[] { lower, lower + 1, lower + 2 }, rows.Select(row => row[0].AsInteger)); + Assert.All(rows, row => Assert.Equal(row[0].AsInteger * 10, row[1].AsInteger)); + } + var stats = db.GetSelectPlanCacheDiagnostics(); + Assert.Equal(1, stats.MissCount); + Assert.Equal(executions - 1, stats.HitCount); + Assert.Equal(1, stats.EntryCount); + } + + [Fact] + public async Task MonotonicRangeBounds_DoNotRetainValueBoundPlans() + { + await using var db = await CreateAsync(); + var template = Prepare("SELECT id FROM t WHERE id >= @lower AND id < @upper ORDER BY id", "lower", "upper"); + for (int lower = 0; lower < 4096; lower++) + { + long[] expected = Enumerable.Range(0, 40).Select(i => (long)i).Where(i => i >= lower && i < lower + 2).ToArray(); + Assert.Equal(expected, await ReadAsync(db, template.Bind([lower, lower + 2]))); + } + var stats = db.GetSelectPlanCacheDiagnostics(); + Assert.Equal(1, stats.EntryCount); + Assert.Equal(1, stats.MissCount); + Assert.Equal(4095, stats.HitCount); + } + + [Theory] + [InlineData(5, 20)] + [InlineData(20, 5)] + [InlineData(8, 8)] + [InlineData(long.MinValue, long.MaxValue)] + public async Task ReversedBoundsAndRowWindows_PreserveInclusiveExclusiveSemantics(long lower, long upper) + { + await using var db = await CreateAsync(); + var template = Prepare("SELECT x.id AS identifier, x.value FROM t x WHERE @lower < x.id AND x.id <= @upper AND x.id >= 1 ORDER BY x.id LIMIT 4 OFFSET 1", "lower", "upper"); + long[] expected = Enumerable.Range(0, 40).Select(i => (long)i) + .Where(i => lower < i && i <= upper && i >= 1).Skip(1).Take(4).ToArray(); + Assert.Equal(expected, await ReadAsync(db, template.Bind([lower, upper]))); + var zero = Prepare("SELECT * FROM t WHERE id > @lower ORDER BY id LIMIT 0", "lower"); + Assert.Empty(await ReadAsync(db, zero.Bind([lower]))); + } + + [Fact] + public async Task RangeProjections_WithChangingBoundsAndRowWindows() + { + await using var db = await CreateAsync(160); + foreach (int limit in new[] { 1, 4, 5, 128 }) + foreach (int offset in new[] { 0, 3, 200 }) + { + var template = Prepare($"SELECT value AS payload, id AS first_key, id AS second_key FROM t WHERE id >= @lower AND id < @upper ORDER BY id LIMIT {limit} OFFSET {offset}", "lower", "upper"); + foreach (int lower in new[] { 10, 20, 180 }) + { + await using var result = await db.ExecuteAsync(template.Bind([lower, lower + 140]), Ct); + Assert.Equal(new[] { "payload", "first_key", "second_key" }, result.Schema.Select(column => column.Name)); + long[] expected = Enumerable.Range(0, 160).Select(i => (long)i) + .Where(i => i >= lower && i < lower + 140).Skip(offset).Take(limit).ToArray(); + var rows = await result.ToListAsync(Ct); + Assert.Equal(expected, rows.Select(row => row[1].AsInteger)); + Assert.All(rows, row => + { + Assert.Equal(row[1].AsInteger * 10, row[0].AsInteger); + Assert.Equal(row[1], row[2]); + }); + } + } + } + + [Fact] + public async Task ExtremeBoundsAndOverlappingResults_KeepExecutionValuesIndependent() + { + await using var db = await CreateAsync(0); + await db.ExecuteAsync($"INSERT INTO t VALUES ({long.MinValue}, 1), (-1, 2), (0, 3), ({long.MaxValue}, 4)", Ct); + var template = Prepare("SELECT id, value FROM t WHERE id >= @lower AND id <= @upper ORDER BY id", "lower", "upper"); + long[] values = [long.MinValue, -1]; + Statement first = template.Bind(values); + values[0] = 0; values[1] = long.MaxValue; + await using var firstResult = await db.ExecuteAsync(first, Ct); + await using var secondResult = await db.ExecuteAsync(template.Bind(values), Ct); + Assert.Equal(new long[] { 0, long.MaxValue }, (await secondResult.ToListAsync(Ct)).Select(row => row[0].AsInteger)); + Assert.Equal(new long[] { long.MinValue, -1 }, (await firstResult.ToListAsync(Ct)).Select(row => row[0].AsInteger)); + Assert.Equal(new[] { long.MaxValue }, await ReadAsync(db, template.Bind([long.MaxValue, long.MaxValue]))); + var exclusive = Prepare("SELECT id FROM t WHERE id > @lower ORDER BY id", "lower"); + Assert.Empty(await ReadAsync(db, exclusive.Bind([long.MaxValue]))); + } + + [Fact] + public async Task NullableColumnAndRolledBackSchemaChanges_RefreshRangeMetadata() + { + await using var db = await CreateAsync(3); + var template = Prepare("SELECT * FROM t WHERE id >= @lower AND id < @upper ORDER BY id", "lower", "upper"); + Assert.Equal(new long[] { 0, 1 }, await ReadAsync(db, template.Bind([0, 2]))); + await db.ExecuteAsync("ALTER TABLE t ADD COLUMN added TEXT", Ct); + await using (var expanded = await db.ExecuteAsync(template.Bind([0, 2]), Ct)) + { + Assert.Equal(new[] { "id", "value", "added" }, expanded.Schema.Select(column => column.Name)); + Assert.All(await expanded.ToListAsync(Ct), row => { Assert.Equal(3, row.Length); Assert.True(row[2].IsNull); }); + } + await db.BeginTransactionAsync(Ct); + await db.ExecuteAsync("DROP TABLE t", Ct); + await db.ExecuteAsync("CREATE TABLE t (value BIGINT, id BIGINT PRIMARY KEY)", Ct); + await db.ExecuteAsync("INSERT INTO t VALUES (900, 1)", Ct); + await using (var changed = await db.ExecuteAsync(template.Bind([0, 2]), Ct)) + { + Assert.Equal(new[] { "value", "id" }, changed.Schema.Select(column => column.Name)); + Assert.Equal(new long[] { 900, 1 }, Assert.Single(await changed.ToListAsync(Ct)).Select(value => value.AsInteger)); + } + await db.RollbackAsync(Ct); + Assert.Equal(new long[] { 0, 1 }, await ReadAsync(db, template.Bind([0, 2]))); + } + + [Fact] + public async Task CachedRangePlan_UsesCurrentRootsWhileOpenReaderKeepsItsSnapshot() + { + await using var db = await CreateAsync(16); + var template = Prepare("SELECT * FROM t WHERE id >= @lower AND id < @upper ORDER BY id", "lower", "upper"); + Assert.Equal(16, (await ReadAsync(db, template.Bind([0, 1024]))).Length); + using var snapshot = db.CreateReaderSession(); + // The prepared operator resolves its root through the reader's frozen catalog. + await using var old = await snapshot.ExecuteReadAsync(template.Bind([0, 1024]), Ct); + Assert.IsType(old.PhysicalRootOperator); + // Grow beyond a leaf page so the cached plan cannot retain a table root from preparation. + await InsertAsync(db, 16, 1008); + Assert.Equal(Enumerable.Range(0, 1024).Select(i => (long)i), await ReadAsync(db, template.Bind([0, 1024]))); + Assert.Equal(Enumerable.Range(0, 16).Select(i => (long)i), (await old.ToListAsync(Ct)).Select(row => row[0].AsInteger)); + using var latest = db.CreateReaderSession(); + await using var current = await latest.ExecuteReadAsync(template.Bind([1000, 1024]), Ct); + Assert.IsType(current.PhysicalRootOperator); + Assert.Equal(Enumerable.Range(1000, 24).Select(i => (long)i), (await current.ToListAsync(Ct)).Select(row => row[0].AsInteger)); + } + + [Theory] + [InlineData(9, false, false)] + [InlineData(10, true, false)] + [InlineData(19, true, false)] + [InlineData(20, false, false)] + [InlineData(12, true, true)] + [InlineData(20, false, true)] + public async Task SerializableRangeRead_TracksOnlyIncludedTableKeys(int insertedKey, bool conflicts, bool emptyRead) + { + await using var db = await CreateAsync(0); + await db.ExecuteAsync(emptyRead ? "INSERT INTO t VALUES (30, 300)" : "INSERT INTO t VALUES (15, 150), (30, 300)", Ct); + var template = Prepare("SELECT * FROM t WHERE id >= @lower AND id < @upper ORDER BY id", "lower", "upper"); + await using var transaction = await db.BeginWriteTransactionAsync(Ct); + await using (var result = await transaction.ExecuteAsync(template.Bind([10, 20]), Ct)) + { + Assert.IsType(result.PhysicalRootOperator); + Assert.Equal(emptyRead ? Array.Empty() : [15L], (await result.ToListAsync(Ct)).Select(row => row[0].AsInteger)); + } + await db.ExecuteAsync($"INSERT INTO t VALUES ({insertedKey}, 1)", Ct); + if (conflicts) + await Assert.ThrowsAsync(() => transaction.CommitAsync(Ct).AsTask()); + else + await transaction.CommitAsync(Ct); + } + + [Fact] + public async Task ReaderSession_UsesBoundedRangesAndPreservesLookupAndIndexPaths() + { + await using var db = await CreateAsync(); + await db.ExecuteAsync("CREATE INDEX ix_value ON t(value)", Ct); + using var reader = db.CreateReaderSession(); + var range = Prepare("SELECT * FROM t WHERE id >= @lower AND id <= @upper ORDER BY id", "lower", "upper"); + foreach (long[] bounds in new long[][] { [2, 4], [7, 7], [10, 12] }) + { + await using var result = await reader.ExecuteReadAsync(range.Bind(bounds), Ct); + if (bounds[0] != bounds[1]) + Assert.IsType(result.PhysicalRootOperator); + Assert.Equal(Enumerable.Range((int)bounds[0], (int)(bounds[1] - bounds[0] + 1)).Select(i => (long)i), + (await result.ToListAsync(Ct)).Select(row => row[0].AsInteger)); + } + + var equality = Prepare("SELECT id FROM t WHERE id = @p", "p"); + await using (var result = await reader.ExecuteReadAsync(equality.Bind([5]), Ct)) + Assert.Equal(5L, Assert.Single(await result.ToListAsync(Ct))[0].AsInteger); + + var indexed = Prepare("SELECT * FROM t WHERE value >= @lower AND value < @upper ORDER BY value", "lower", "upper"); + await using var indexedResult = await reader.ExecuteReadAsync(indexed.Bind([20, 50]), Ct); + Assert.IsType(indexedResult.PhysicalRootOperator); + Assert.Equal(new long[] { 2, 3, 4 }, (await indexedResult.ToListAsync(Ct)).Select(row => row[0].AsInteger)); + } + + [Theory] + [InlineData(65)] + [InlineData(2048)] + public async Task ReaderSession_ChangingBoundsReusesOnePlanWithoutRetainingValues(int cardinality) + { + await using var db = await CreateAsync(cardinality + 2); + using var reader = db.CreateReaderSession(); + var template = Prepare("SELECT id, value FROM t WHERE id >= @lower AND id < @upper ORDER BY id", "lower", "upper"); + int executions = cardinality * 2; + for (int i = 0; i < executions; i++) + { + long lower = i % cardinality; + await using var result = await reader.ExecuteReadAsync(template.Bind([lower, lower + 3]), Ct); + Assert.IsType(result.PhysicalRootOperator); + var rows = await result.ToListAsync(Ct); + Assert.Equal(new[] { lower, lower + 1, lower + 2 }, rows.Select(row => row[0].AsInteger)); + Assert.All(rows, row => Assert.Equal(row[0].AsInteger * 10, row[1].AsInteger)); + } + var planner = Assert.IsType(typeof(Database.ReaderSession) + .GetField("_planner", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .GetValue(reader)); + var stats = planner.GetSelectPlanCacheDiagnostics(); + Assert.Equal(1, stats.MissCount); + Assert.Equal(executions - 1, stats.HitCount); + Assert.Equal(1, stats.EntryCount); + } +} diff --git a/tests/CSharpDB.Tests/PreparedRangeBatchLifecycleTests.cs b/tests/CSharpDB.Tests/PreparedRangeBatchLifecycleTests.cs new file mode 100644 index 00000000..61549e0d --- /dev/null +++ b/tests/CSharpDB.Tests/PreparedRangeBatchLifecycleTests.cs @@ -0,0 +1,127 @@ +using CSharpDB.Engine; +using CSharpDB.Execution; +using CSharpDB.Primitives; +using CSharpDB.Sql; + +namespace CSharpDB.Tests; + +public sealed class PreparedRangeBatchLifecycleTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Theory] + [InlineData("scan", 130)] + [InlineData("projection", 130)] + [InlineData("limit", 70)] + [InlineData("equality", 130)] + public async Task RowReadsThenReopenedBatchReads_PreserveBatchOwnership(string kind, int expectedRows) + { + await using var db = await Database.OpenInMemoryAsync(Ct); + db.PreferSyncPointLookups = false; + await db.ExecuteAsync("CREATE TABLE t (id INTEGER PRIMARY KEY, value INTEGER)", Ct); + await db.BeginTransactionAsync(Ct); + var insert = db.PrepareInsertBatch("t", 130); + for (int value = 0; value < 130; value++) + insert.AddRow([DbValue.FromInteger(value), DbValue.FromInteger(kind == "equality" ? 0 : value)]); + await insert.ExecuteAsync(Ct); + await db.CommitAsync(Ct); + await db.ExecuteAsync("CREATE INDEX ix_value ON t(value)", Ct); + + string projection = kind is "scan" or "equality" ? "*" : "id, value"; + string limit = kind == "limit" ? " LIMIT 70" : ""; + string comparison = kind == "equality" ? "=" : ">="; + var template = Assert.IsType(PreparedSelectTemplate.TryCreate( + Parser.Parse($"SELECT {projection} FROM t WHERE value {comparison} @lower ORDER BY value{limit}"), ["lower"])); + await using var result = await db.ExecuteAsync(template.Bind([0]), Ct); + IOperator op = kind switch + { + "scan" => Assert.IsType(result.PhysicalRootOperator), + "projection" => Assert.IsType(result.PhysicalRootOperator), + "equality" => Assert.IsType(result.PhysicalRootOperator), + _ => Assert.IsType(result.PhysicalRootOperator), + }; + var batches = Assert.IsAssignableFrom(op); + var controller = Assert.IsAssignableFrom(op); + + // Merely inspecting the batch contract must remain valid before the operator is opened. + RowBatch beforeOpen = batches.CurrentBatch; + Assert.Equal(2, beforeOpen.ColumnCount); + Assert.Equal(0, beforeOpen.Count); + await op.OpenAsync(Ct); + RowBatch afterOpen = batches.CurrentBatch; + Assert.NotSame(beforeOpen, afterOpen); + Assert.Equal(0, afterOpen.Count); + for (int value = 0; value < 4; value++) + { + Assert.True(await op.MoveNextAsync(Ct)); + Assert.Equal(new long[] { value, kind == "equality" ? 0 : value }, op.Current.Select(cell => cell.AsInteger)); + } + + await op.DisposeAsync(); + await op.OpenAsync(Ct); + RowBatch reopened = batches.CurrentBatch; + Assert.NotSame(afterOpen, reopened); + Assert.Equal(0, reopened.Count); + controller.SetReuseCurrentBatch(false); + Assert.False(batches.ReusesCurrentBatch); + RowBatch afterDisablingReuse = batches.CurrentBatch; + Assert.NotSame(reopened, afterDisablingReuse); + Assert.Equal(0, afterDisablingReuse.Count); + + var retained = new List<(RowBatch Batch, long[][] Rows)>(); + var actual = new List(); + while (await batches.MoveNextBatchAsync(Ct)) + { + RowBatch batch = batches.CurrentBatch; + Assert.DoesNotContain(retained, previous => ReferenceEquals(previous.Batch, batch)); + long[][] rows = Snapshot(batch); + retained.Add((batch, rows)); + foreach (long[] row in rows) + { + Assert.Equal(kind == "equality" ? 0 : row[0], row[1]); + actual.Add(row[0]); + } + } + Assert.Equal(Enumerable.Range(0, expectedRows).Select(value => (long)value), actual); + Assert.True(retained.Count >= 2); + + await op.DisposeAsync(); + await op.OpenAsync(Ct); + RowBatch finalEmpty = batches.CurrentBatch; + Assert.Equal(0, finalEmpty.Count); + controller.SetReuseCurrentBatch(true); + Assert.True(await batches.MoveNextBatchAsync(Ct)); + Assert.Same(finalEmpty, batches.CurrentBatch); + Assert.Equal(0, batches.CurrentBatch.GetRowSpan(0)[0].AsInteger); + foreach (var previous in retained) + { + Assert.NotSame(previous.Batch, batches.CurrentBatch); + Assert.Equal(previous.Rows.Length, previous.Batch.Count); + for (int row = 0; row < previous.Rows.Length; row++) + Assert.Equal(previous.Rows[row], previous.Batch.GetRowSpan(row).ToArray().Select(cell => cell.AsInteger)); + } + } + + [Fact] + public async Task ZeroLimit_ExposesEmptyBatchesWithoutConsumingRows() + { + ColumnDefinition[] schema = [new() { Name = "id", Type = DbType.Integer }]; + var source = new MaterializedOperator([[DbValue.FromInteger(1)]], schema); + await using var op = new LimitOperator(source, 0); + var batches = (IBatchOperator)op; + RowBatch beforeOpen = batches.CurrentBatch; + op.SetReuseCurrentBatch(false); + await op.OpenAsync(Ct); + Assert.NotSame(beforeOpen, batches.CurrentBatch); + Assert.False(await op.MoveNextAsync(Ct)); + Assert.False(await batches.MoveNextBatchAsync(Ct)); + Assert.Equal(1, batches.CurrentBatch.ColumnCount); + Assert.Equal(0, batches.CurrentBatch.Count); + Assert.True(await source.MoveNextAsync(Ct)); + Assert.Equal(1, source.Current[0].AsInteger); + } + + private static long[][] Snapshot(RowBatch batch) + => Enumerable.Range(0, batch.Count) + .Select(row => batch.GetRowSpan(row).ToArray().Select(cell => cell.AsInteger).ToArray()).ToArray(); +} diff --git a/tests/CSharpDB.Tests/PreparedSelectPlanTests.cs b/tests/CSharpDB.Tests/PreparedSelectPlanTests.cs new file mode 100644 index 00000000..d73b4146 --- /dev/null +++ b/tests/CSharpDB.Tests/PreparedSelectPlanTests.cs @@ -0,0 +1,427 @@ +using CSharpDB.Engine; +using CSharpDB.Execution; +using CSharpDB.Primitives; +using CSharpDB.Sql; + +namespace CSharpDB.Tests; + +public sealed class PreparedSelectPlanTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + private static PreparedSelectTemplate Prepare(string sql, params string[] names) + => Assert.IsType(PreparedSelectTemplate.TryCreate(Parser.Parse(sql), names)); + + private static async Task CreateAsync(int count = 40) + { + var db = await Database.OpenInMemoryAsync(Ct); + db.PreferSyncPointLookups = false; + await db.ExecuteAsync("CREATE TABLE t (id INTEGER PRIMARY KEY, value BIGINT)", Ct); + await db.BeginTransactionAsync(Ct); + var batch = db.PrepareInsertBatch("t", count); + for (int i = 0; i < count; i++) batch.AddRow([DbValue.FromInteger(i), DbValue.FromInteger(i)]); + await batch.ExecuteAsync(Ct); + await db.CommitAsync(Ct); + await db.ExecuteAsync("CREATE INDEX ix_value ON t(value)", Ct); + db.ResetSelectPlanCacheDiagnostics(); + return db; + } + + private static async Task ReadAsync(Database db, Statement statement) + { + await using var result = await db.ExecuteAsync(statement, Ct); + return (await result.ToListAsync(Ct)).Select(row => row[0].AsInteger).ToArray(); + } + + [Fact] + public async Task PrimaryKeyRangeObservability_ReportsScanAndExecutionLocalEquality() + { + await using var db = await CreateAsync(); + var planner = Assert.IsType(typeof(Database) + .GetField("_planner", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .GetValue(db)); + var observer = new PreparedRangeObserver(); + planner.PlanRuntimeObserver = observer; + var template = Prepare("SELECT id FROM t WHERE id >= @lower AND id <= @upper ORDER BY id", "lower", "upper"); + + Assert.Equal(new long[] { 2, 3, 4, 5 }, await ReadAsync(db, template.Bind([2, 5]))); + Assert.Equal(new long[] { 7 }, await ReadAsync(db, template.Bind([7, 7]))); + Assert.Equal(new long[] { 10, 11, 12 }, await ReadAsync(db, template.Bind([10, 12]))); + Assert.Equal(new[] { false, true, true }, observer.Hits); + Assert.Equal(QueryPlanAccessPathCategory.IndexScan, observer.Selections[0].AccessPath); + Assert.Null(observer.Selections[0].EstimatedRows); + Assert.Equal(QueryPlanAccessPathCategory.PrimaryKeyLookup, observer.Selections[1].AccessPath); + Assert.Equal(1, observer.Selections[1].EstimatedRows); + Assert.Equal(QueryPlanAccessPathCategory.IndexScan, observer.Selections[2].AccessPath); + Assert.Null(observer.Selections[2].EstimatedRows); + } + + private sealed class PreparedRangeObserver : IQueryPlanRuntimeObserver + { + internal List Hits { get; } = []; + internal List Selections { get; } = []; + public void OnPlanCacheLookup(bool hit) => Hits.Add(hit); + public void OnAccessPathSelected(in QueryPlanRuntimeSelection selection) => Selections.Add(selection); + public void OnPlanChanged(QueryPlanChangeKind change) { } + } + + [Theory] + [InlineData(1)] + [InlineData(64)] + [InlineData(65)] + [InlineData(256)] + [InlineData(2048)] + public async Task ChangingValues_ReusesOnePlan(int cardinality) + { + await using var db = await CreateAsync(cardinality); + var template = Prepare("SELECT id, value FROM t WHERE value = @p", "p"); + int executions = 2 * cardinality + 3; + for (int i = 0; i < executions; i++) + Assert.Equal(new[] { (long)(i % cardinality) }, await ReadAsync(db, template.Bind([i % cardinality]))); + var stats = db.GetSelectPlanCacheDiagnostics(); + Assert.Equal(1, stats.MissCount); + Assert.Equal(executions - 1, stats.HitCount); + Assert.Equal(1, stats.EntryCount); + } + + [Theory] + [InlineData(1, false)] + [InlineData(1, true)] + [InlineData(65, false)] + [InlineData(65, true)] + [InlineData(2048, false)] + [InlineData(2048, true)] + public async Task PrimaryKeyChangingValues_ReusesOnePlan(int cardinality, bool preferSync) + { + await using var db = await CreateAsync(cardinality); + db.PreferSyncPointLookups = preferSync; + var template = Prepare("SELECT id FROM t WHERE id = @p", "p"); + int executions = 2 * cardinality + 3; + for (int i = 0; i < executions; i++) + Assert.Equal(new[] { (long)(i % cardinality) }, await ReadAsync(db, template.Bind([i % cardinality]))); + var stats = db.GetSelectPlanCacheDiagnostics(); + Assert.Equal(1, stats.MissCount); + Assert.Equal(executions - 1, stats.HitCount); + Assert.Equal(1, stats.EntryCount); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task PrimaryKeyProjections_PreserveValuesMetadataAndLookupPaths(bool preferSync) + { + await using var db = await CreateAsync(); + await db.ExecuteAsync("UPDATE t SET value = 1005 WHERE id = 5", Ct); + // Warm the table page before checking the optional cache-only execution path. + await using (var warm = await db.ExecuteAsync("SELECT * FROM t WHERE id = 5", Ct)) + _ = await warm.ToListAsync(Ct); + db.PreferSyncPointLookups = preferSync; + + var star = Prepare("SELECT * FROM t WHERE id = @p", "p"); + await using (var result = await db.ExecuteAsync(star.Bind([5]), Ct)) + { + if (preferSync) Assert.Null(result.PhysicalRootOperator); + else Assert.IsType(result.PhysicalRootOperator); + var row = Assert.Single(await result.ToListAsync(Ct)); + Assert.Equal(new long[] { 5, 1005 }, row.Select(value => value.AsInteger)); + Assert.Equal(new[] { "id", "value" }, result.Schema.Select(column => column.Name)); + } + + var keyOnly = Prepare("SELECT x.id AS first_key, x.id AS second_key FROM t x WHERE x.id = @p", "p"); + await using (var result = await db.ExecuteAsync(keyOnly.Bind([5]), Ct)) + { + if (preferSync) Assert.Null(result.PhysicalRootOperator); + else Assert.IsType(result.PhysicalRootOperator); + var row = Assert.Single(await result.ToListAsync(Ct)); + Assert.Equal(new long[] { 5, 5 }, row.Select(value => value.AsInteger)); + Assert.Equal(new[] { "first_key", "second_key" }, result.Schema.Select(column => column.Name)); + } + + var columns = Prepare("SELECT x.value AS payload, x.id AS identifier FROM t x WHERE @p = x.id", "p"); + await using (var result = await db.ExecuteAsync(columns.Bind([5]), Ct)) + { + var row = Assert.Single(await result.ToListAsync(Ct)); + Assert.Equal(new long[] { 1005, 5 }, row.Select(value => value.AsInteger)); + Assert.Equal(new[] { "payload", "identifier" }, result.Schema.Select(column => column.Name)); + } + + Assert.Empty(await ReadAsync(db, star.Bind([500]))); + Assert.Empty(await ReadAsync(db, keyOnly.Bind([500]))); + Assert.Empty(await ReadAsync(db, columns.Bind([500]))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task PrimaryKeyEquality_IntersectsBoundsAndAppliesRowWindows(bool preferSync) + { + await using var db = await CreateAsync(); + db.PreferSyncPointLookups = preferSync; + var template = Prepare("SELECT id FROM t WHERE id = @p AND @lower <= id AND id < @upper ORDER BY id LIMIT 1 OFFSET 0", "p", "lower", "upper"); + Assert.Equal(new long[] { 5 }, await ReadAsync(db, template.Bind([5, 5, 6]))); + Assert.Empty(await ReadAsync(db, template.Bind([5, 6, 10]))); + Assert.Empty(await ReadAsync(db, template.Bind([5, 0, 5]))); + Assert.Equal(new long[] { 6 }, await ReadAsync(db, template.Bind([6, long.MinValue, long.MaxValue]))); + + var limited = Prepare("SELECT * FROM t WHERE id = @p ORDER BY id LIMIT 0", "p"); + var offset = Prepare("SELECT id AS identifier FROM t WHERE id = @p ORDER BY id LIMIT 1 OFFSET 1", "p"); + Assert.Empty(await ReadAsync(db, limited.Bind([5]))); + Assert.Empty(await ReadAsync(db, offset.Bind([5]))); + } + + [Fact] + public async Task PrimaryKeyLongExtremesAndOverlappingResults_RetainEachExecutionValue() + { + await using var db = await Database.OpenInMemoryAsync(Ct); + await db.ExecuteAsync("CREATE TABLE t (id BIGINT PRIMARY KEY, value BIGINT)", Ct); + await db.ExecuteAsync($"INSERT INTO t VALUES ({long.MinValue}, 10), ({long.MaxValue}, 20)", Ct); + db.PreferSyncPointLookups = false; + var template = Prepare("SELECT id, value FROM t WHERE id = @p", "p"); + long[] values = [long.MinValue]; + var first = template.Bind(values); + values[0] = long.MaxValue; + await using var firstResult = await db.ExecuteAsync(first, Ct); + await using var secondResult = await db.ExecuteAsync(template.Bind(values), Ct); + var secondRow = Assert.Single(await secondResult.ToListAsync(Ct)); + var firstRow = Assert.Single(await firstResult.ToListAsync(Ct)); + Assert.Equal(new long[] { long.MinValue, 10 }, firstRow.Select(value => value.AsInteger)); + Assert.Equal(new long[] { long.MaxValue, 20 }, secondRow.Select(value => value.AsInteger)); + Assert.Empty(await ReadAsync(db, template.Bind([0]))); + } + + [Fact] + public async Task PrimaryKeySchemaReplacementAndRollback_RefreshMetadataAndTableRoots() + { + await using var db = await CreateAsync(); + var template = Prepare("SELECT id, value FROM t WHERE id = @p", "p"); + Assert.Equal(new long[] { 5 }, await ReadAsync(db, template.Bind([5]))); + await db.BeginTransactionAsync(Ct); + await db.ExecuteAsync("UPDATE t SET id = 105 WHERE id = 5", Ct); + Assert.Equal(new long[] { 105 }, await ReadAsync(db, template.Bind([105]))); + Assert.Empty(await ReadAsync(db, template.Bind([5]))); + await db.RollbackAsync(Ct); + Assert.Equal(new long[] { 5 }, await ReadAsync(db, template.Bind([5]))); + Assert.Empty(await ReadAsync(db, template.Bind([105]))); + + await db.BeginTransactionAsync(Ct); + await db.ExecuteAsync("DROP TABLE t", Ct); + await db.ExecuteAsync("CREATE TABLE t (value BIGINT, id INTEGER PRIMARY KEY)", Ct); + await db.ExecuteAsync("INSERT INTO t VALUES (500, 5)", Ct); + await using (var changed = await db.ExecuteAsync(template.Bind([5]), Ct)) + Assert.Equal(new long[] { 5, 500 }, Assert.Single(await changed.ToListAsync(Ct)).Select(value => value.AsInteger)); + await db.RollbackAsync(Ct); + await using var restored = await db.ExecuteAsync(template.Bind([5]), Ct); + Assert.Equal(new long[] { 5, 5 }, Assert.Single(await restored.ToListAsync(Ct)).Select(value => value.AsInteger)); + Assert.Equal(1, db.GetSelectPlanCacheDiagnostics().EntryCount); + } + + [Theory] + [InlineData("INTEGER", true)] + [InlineData("SMALLINT", false)] + [InlineData("TINYINT", false)] + public async Task BackedPrimaryKeys_DoNotTreatDeclaredKeyValuesAsRowIds(string keyType, bool composite) + { + await using var db = await Database.OpenInMemoryAsync(Ct); + string definition = composite + ? $"id {keyType}, value INTEGER, PRIMARY KEY (id, value)" + : $"id {keyType} PRIMARY KEY, value INTEGER"; + await db.ExecuteAsync($"CREATE TABLE t ({definition})", Ct); + await db.ExecuteAsync(composite + ? "INSERT INTO t VALUES (11, 101), (11, 102), (22, 201)" + : "INSERT INTO t VALUES (11, 101), (22, 201)", Ct); + Assert.NotNull(Assert.Single(db.GetTableSchema("t")!.KeyConstraints).BackingIndexName); + + var template = Prepare("SELECT value FROM t WHERE id = @p", "p"); + long[] firstExpected = composite ? [101, 102] : [101]; + for (int i = 0; i < 3; i++) + { + Assert.Equal(firstExpected, (await ReadAsync(db, template.Bind([11]))).Order()); + Assert.Equal(new long[] { 201 }, await ReadAsync(db, template.Bind([22]))); + } + Assert.Empty(await ReadAsync(db, template.Bind([1]))); + } + + [Fact] + public async Task MonotonicValues_DoNotAccumulateBoundPlans() + { + await using var db = await CreateAsync(); + var template = Prepare("SELECT id FROM t WHERE value = @p", "p"); + for (int i = 0; i < 4096; i++) + Assert.Equal(i < 40 ? new[] { (long)i } : [], await ReadAsync(db, template.Bind([i]))); + Assert.Equal(1, db.GetSelectPlanCacheDiagnostics().EntryCount); + Assert.Equal(4095, db.GetSelectPlanCacheDiagnostics().HitCount); + } + + [Theory] + [InlineData(3, 20)] + [InlineData(20, 3)] + [InlineData(8, 8)] + [InlineData(long.MinValue, long.MaxValue)] + public async Task RangeBounds_OrderAndOffset_MatchPredicate(long lower, long upper) + { + await using var db = await CreateAsync(); + var template = Prepare("SELECT x.id AS identifier, x.value FROM t x WHERE @lower <= x.value AND x.value < @upper ORDER BY x.value LIMIT 4 OFFSET 1", "lower", "upper"); + long[] expected = Enumerable.Range(0, 40).Select(i => (long)i).Where(i => i >= lower && i < upper).Skip(1).Take(4).ToArray(); + Assert.Equal(expected, await ReadAsync(db, template.Bind([lower, upper]))); + } + + [Fact] + public async Task MultipleBounds_EqualityAndLongExtremes_AreIntersectedWithoutOverflow() + { + await using var db = await CreateAsync(); + await db.ExecuteAsync($"INSERT INTO t VALUES (100, {long.MinValue}), (101, {long.MaxValue}), (102, NULL)", Ct); + var equality = Prepare("SELECT id FROM t WHERE value = @p AND value >= @lower AND @upper >= value", "p", "lower", "upper"); + Assert.Equal(new long[] { 100 }, await ReadAsync(db, equality.Bind([long.MinValue, long.MinValue, long.MaxValue]))); + Assert.Equal(new long[] { 101 }, await ReadAsync(db, equality.Bind([long.MaxValue, long.MinValue, long.MaxValue]))); + Assert.Empty(await ReadAsync(db, equality.Bind([10, 11, 12]))); + var range = Prepare("SELECT id FROM t WHERE value > @p ORDER BY value", "p"); + Assert.Empty(await ReadAsync(db, range.Bind([long.MaxValue]))); + Assert.DoesNotContain(102, await ReadAsync(db, range.Bind([long.MinValue]))); + } + + [Fact] + public async Task ExecutionValues_AndOverlappingReaders_AreIndependent() + { + await using var db = await CreateAsync(); + var template = Prepare("SELECT id FROM t WHERE value >= @p ORDER BY value LIMIT 3", "p"); + long[] values = [3]; + var first = template.Bind(values); + values[0] = 20; + await using var firstResult = await db.ExecuteAsync(first, Ct); + await using var secondResult = await db.ExecuteAsync(template.Bind(values), Ct); + Assert.Equal(new long[] { 20, 21, 22 }, (await secondResult.ToListAsync(Ct)).Select(r => r[0].AsInteger)); + Assert.Equal(new long[] { 3, 4, 5 }, (await firstResult.ToListAsync(Ct)).Select(r => r[0].AsInteger)); + } + + [Fact] + public async Task IndexAndTableSchemaChanges_InvalidateCachedMetadata() + { + await using var db = await CreateAsync(); + var template = Prepare("SELECT id FROM t WHERE value = @p", "p"); + Assert.Equal(new long[] { 5 }, await ReadAsync(db, template.Bind([5]))); + await db.ExecuteAsync("DROP INDEX ix_value", Ct); + Assert.Equal(new long[] { 5 }, await ReadAsync(db, template.Bind([5]))); + await db.ExecuteAsync("CREATE INDEX ix_value ON t(value)", Ct); + Assert.Equal(new long[] { 6 }, await ReadAsync(db, template.Bind([6]))); + await db.ExecuteAsync("DROP TABLE t", Ct); + await db.ExecuteAsync("CREATE TABLE t (value INTEGER, id INTEGER PRIMARY KEY)", Ct); + await db.ExecuteAsync("INSERT INTO t VALUES (5, 99)", Ct); + await db.ExecuteAsync("CREATE INDEX ix_value ON t(value)", Ct); + Assert.Equal(new long[] { 99 }, await ReadAsync(db, template.Bind([5]))); + Assert.Equal(1, db.GetSelectPlanCacheDiagnostics().EntryCount); + } + + [Fact] + public async Task TransactionRollback_DoesNotRetainIndexContentsOrUncommittedSchema() + { + await using var db = await CreateAsync(); + var template = Prepare("SELECT id FROM t WHERE value = @p", "p"); + Assert.Equal(new long[] { 1 }, await ReadAsync(db, template.Bind([1]))); + await db.BeginTransactionAsync(Ct); + await db.ExecuteAsync("UPDATE t SET value = 700 WHERE id = 1", Ct); + Assert.Equal(new long[] { 1 }, await ReadAsync(db, template.Bind([700]))); + await db.RollbackAsync(Ct); + Assert.Empty(await ReadAsync(db, template.Bind([700]))); + Assert.Equal(new long[] { 1 }, await ReadAsync(db, template.Bind([1]))); + await db.BeginTransactionAsync(Ct); + await db.ExecuteAsync("DROP INDEX ix_value", Ct); + Assert.Equal(new long[] { 1 }, await ReadAsync(db, template.Bind([1]))); + await db.RollbackAsync(Ct); + Assert.Equal(new long[] { 2 }, await ReadAsync(db, template.Bind([2]))); + } + + [Fact] + public async Task TemporaryTableShadow_DoesNotReusePersistentIndexMetadata() + { + await using var db = await CreateAsync(); + var template = Prepare("SELECT id FROM t WHERE value = @p", "p"); + Assert.Equal(new long[] { 1 }, await ReadAsync(db, template.Bind([1]))); + await db.ExecuteAsync("CREATE TEMP TABLE t (id INTEGER PRIMARY KEY, value BIGINT)", Ct); + await db.ExecuteAsync("INSERT INTO t VALUES (90, 1)", Ct); + Assert.Equal(new long[] { 90 }, await ReadAsync(db, template.Bind([1]))); + await db.ExecuteAsync("DROP TEMP TABLE t", Ct); + Assert.Equal(new long[] { 1 }, await ReadAsync(db, template.Bind([1]))); + } + + [Fact] + public async Task NonUniqueKeysAndStarProjection_ReturnEveryMatchingRow() + { + await using var db = await CreateAsync(); + await db.ExecuteAsync("UPDATE t SET value = 10 WHERE id < 4", Ct); + var equality = Prepare("SELECT * FROM t WHERE value = @p", "p"); + var range = Prepare("SELECT * FROM t WHERE value >= @lower AND value < @upper ORDER BY value", "lower", "upper"); + Assert.Equal(new long[] { 0, 1, 2, 3, 10 }, (await ReadAsync(db, equality.Bind([10]))).Order()); + Assert.Equal(new long[] { 0, 1, 2, 3, 10 }, (await ReadAsync(db, range.Bind([10, 11]))).Order()); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(4)] + [InlineData(5)] + public async Task SmallOrderedRangeLimits_PreserveDuplicateRowsAndChangingBounds(int limit) + { + await using var db = await Database.OpenInMemoryAsync(Ct); + await db.ExecuteAsync("CREATE TABLE t (id INTEGER PRIMARY KEY, value BIGINT, payload BIGINT)", Ct); + await db.ExecuteAsync("INSERT INTO t VALUES " + + "(1, 10, 1001), (2, 10, 1002), (3, 10, 1003), " + + "(4, 20, 1004), (5, 20, 1005), (6, 20, 1006), " + + "(7, 30, 1007), (8, 30, 1008), (9, 30, 1009)", Ct); + await db.ExecuteAsync("CREATE INDEX ix_value ON t(value)", Ct); + + foreach (string projection in new[] { "id, value", "*" }) + { + var template = Prepare($"SELECT {projection} FROM t WHERE value >= @p ORDER BY value LIMIT {limit}", "p"); + foreach (long lower in new long[] { 10, 20, 30, 99, 10 }) + { + long[] expectedIds = Enumerable.Range(1, 9).Select(id => (long)id) + .Where(id => ((id - 1) / 3 + 1) * 10 >= lower).Take(limit).ToArray(); + await using var result = await db.ExecuteAsync(template.Bind([lower]), Ct); + var rows = await result.ToListAsync(Ct); + Assert.Equal(expectedIds.Length, rows.Count); + Assert.Equal(expectedIds, rows.Select(row => row[0].AsInteger)); + Assert.Equal(expectedIds.Select(id => ((id - 1) / 3 + 1) * 10), rows.Select(row => row[1].AsInteger)); + if (projection == "*") + Assert.Equal(expectedIds.Select(id => 1000 + id), rows.Select(row => row[2].AsInteger)); + } + } + } + + [Fact] + public async Task UniqueIndexAndIndexReplacement_PreserveLookupResults() + { + await using var db = await CreateAsync(); + await db.ExecuteAsync("CREATE UNIQUE INDEX ux_value ON t(value)", Ct); + var template = Prepare("SELECT id FROM t WHERE value = @p", "p"); + Assert.Equal(new long[] { 7 }, await ReadAsync(db, template.Bind([7]))); + Assert.Empty(await ReadAsync(db, template.Bind([700]))); + await db.ExecuteAsync("DROP INDEX ux_value", Ct); + Assert.Equal(new long[] { 8 }, await ReadAsync(db, template.Bind([8]))); + } + + [Fact] + public async Task AnalyzedFrequentValues_StillUseTheOrdinarySelectivityDecision() + { + await using var db = await CreateAsync(200); + await db.ExecuteAsync("UPDATE t SET value = 0 WHERE id < 199", Ct); + await db.ExecuteAsync("ANALYZE t", Ct); + var template = Prepare("SELECT id FROM t WHERE value = @p", "p"); + Assert.Equal(199, (await ReadAsync(db, template.Bind([0]))).Length); + // The frequent value fell back to an ordinary bound plan in addition to the template metadata. + Assert.Equal(2, db.GetSelectPlanCacheDiagnostics().EntryCount); + Assert.Equal(new long[] { 199 }, await ReadAsync(db, template.Bind([199]))); + Assert.Equal(2, db.GetSelectPlanCacheDiagnostics().EntryCount); + } + + [Fact] + public async Task TemplateCache_IsBoundedAndEvictedTemplatesCanExecuteAgain() + { + await using var db = await CreateAsync(); + var templates = Enumerable.Range(0, 300).Select(_ => Prepare("SELECT id FROM t WHERE value = @p", "p")).ToArray(); + foreach (var template in templates) Assert.Equal(new long[] { 5 }, await ReadAsync(db, template.Bind([5]))); + Assert.Equal(256, db.GetSelectPlanCacheDiagnostics().EntryCount); + long misses = db.GetSelectPlanCacheDiagnostics().MissCount; + Assert.Equal(new long[] { 6 }, await ReadAsync(db, templates[0].Bind([6]))); + Assert.Equal(misses + 1, db.GetSelectPlanCacheDiagnostics().MissCount); + Assert.Equal(256, db.GetSelectPlanCacheDiagnostics().EntryCount); + } +} diff --git a/tests/CSharpDB.Tests/PrimaryKeyRangeMutationTests.cs b/tests/CSharpDB.Tests/PrimaryKeyRangeMutationTests.cs new file mode 100644 index 00000000..bfb45dc6 --- /dev/null +++ b/tests/CSharpDB.Tests/PrimaryKeyRangeMutationTests.cs @@ -0,0 +1,217 @@ +using CSharpDB.Engine; +using CSharpDB.Primitives; + +namespace CSharpDB.Tests; + +public sealed class PrimaryKeyRangeMutationTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + public static IEnumerable Predicates() + { + (string Sql, long[] Ids, bool Indexed)[] cases = + [ + ("id >= 40 AND id < 44", [40, 41, 42, 43], true), + ("40 <= id AND 44 > id", [40, 41, 42, 43], true), + ("id BETWEEN 40 AND 43", [40, 41, 42, 43], true), + ("id >= 38 AND id >= 40 AND id <= 45 AND id < 44", [40, 41, 42, 43], true), + ("id > 124", [125, 126, 127], true), + ("id < 3", [0, 1, 2], true), + ("id >= 44 AND id < 40", [], true), + ("id > 40 AND id <= 40", [], true), + ("id >= 200 AND id < 204", [], true), + ("id >= 40 AND id < 44 AND (value = 410 OR value = 430)", [41, 43], true), + ("id >= NULL AND id < 4", [], true), + ("(id >= 40 AND id < 44) OR id = 1", [1, 40, 41, 42, 43], false), + ]; + foreach (bool delete in new[] { false, true }) + foreach (var item in cases) + yield return [delete, item.Sql, item.Ids, item.Indexed]; + } + + [Theory] + [MemberData(nameof(Predicates))] + public async Task RangeMutation_PreservesBoundsResidualsAndNeighborRows( + bool delete, string predicate, long[] targets, bool indexed) + { + await using var db = await CreateAsync(); + db.ResetMutationTargetCollectionDiagnostics(); + await using var result = await db.ExecuteAsync( + (delete ? "DELETE FROM t" : "UPDATE t SET value = value + 1") + $" WHERE {predicate}", Ct); + Assert.Equal(targets.Length, result.RowsAffected); + List rows = await RowsAsync(db); + Assert.Equal(128 - (delete ? targets.Length : 0), rows.Count); + Assert.Equal(Enumerable.Range(0, 128).Select(i => (long)i).Where(i => !delete || !targets.Contains(i)), + rows.Select(row => row[0].AsInteger)); + foreach (DbValue[] row in rows) + { + long id = row[0].AsInteger; + Assert.Equal(id * 10 + (!delete && targets.Contains(id) ? 1 : 0), row[1].AsInteger); + Assert.Equal($"payload-{id}", row[2].AsText); + } + var diagnostics = db.GetMutationTargetCollectionDiagnosticsSnapshot(); + Assert.Equal(indexed ? 1 : 0, diagnostics.IndexedCollectionCount); + Assert.Equal(indexed ? 0 : 1, diagnostics.ScannedCollectionCount); + } + + [Fact] + public async Task PrimaryKeyChange_CollectsStableTargetsAndMaintainsSecondaryIndex() + { + await using var db = await CreateAsync(); + await db.ExecuteAsync("CREATE INDEX ix_value ON t(value)", Ct); + await using var result = await db.ExecuteAsync( + "UPDATE t SET id = id + 1000, value = value + 1 WHERE id >= 40 AND id < 44", Ct); + Assert.Equal(4, result.RowsAffected); + Assert.Equal(128, (await RowsAsync(db)).Count); + for (int id = 40; id < 44; id++) + { + await using var old = await db.ExecuteAsync($"SELECT id FROM t WHERE id = {id} OR value = {id * 10}", Ct); + Assert.Empty(await old.ToListAsync(Ct)); + await using var current = await db.ExecuteAsync($"SELECT id, payload FROM t WHERE value = {id * 10 + 1}", Ct); + DbValue[] row = Assert.Single(await current.ToListAsync(Ct)); + Assert.Equal(id + 1000, row[0].AsInteger); + Assert.Equal($"payload-{id}", row[1].AsText); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Rollback_RestoresRowsAndSecondaryIndex(bool delete) + { + await using var db = await CreateAsync(); + await db.ExecuteAsync("CREATE INDEX ix_value ON t(value)", Ct); + await db.BeginTransactionAsync(Ct); + await db.ExecuteAsync((delete ? "DELETE FROM t" : "UPDATE t SET value = value + 1") + + " WHERE id >= 40 AND id < 44", Ct); + await db.RollbackAsync(Ct); + Assert.Equal(128, (await RowsAsync(db)).Count); + await using var result = await db.ExecuteAsync("SELECT id, value FROM t WHERE value BETWEEN 400 AND 430 ORDER BY id", Ct); + List rows = await result.ToListAsync(Ct); + Assert.Equal(new long[] { 40, 41, 42, 43 }, rows.Select(row => row[0].AsInteger)); + Assert.All(rows, row => Assert.Equal(row[0].AsInteger * 10, row[1].AsInteger)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ExtremeBounds_DoNotOverflow(bool delete) + { + await using var db = await Database.OpenInMemoryAsync(Ct); + await db.ExecuteAsync("CREATE TABLE t (id BIGINT PRIMARY KEY, value INTEGER)", Ct); + await db.ExecuteAsync($"INSERT INTO t VALUES ({long.MinValue}, 1), (-1, 2), (0, 3), ({long.MaxValue}, 4)", Ct); + string mutation = delete ? "DELETE FROM t" : "UPDATE t SET value = value + 10"; + foreach (string empty in new[] { $"id > {long.MaxValue}", $"id < {long.MinValue}" }) + { + await using var zero = await db.ExecuteAsync($"{mutation} WHERE {empty}", Ct); + Assert.Equal(0, zero.RowsAffected); + } + await using var changed = await db.ExecuteAsync($"{mutation} WHERE id > {long.MinValue} AND id < {long.MaxValue}", Ct); + Assert.Equal(2, changed.RowsAffected); + await using var result = await db.ExecuteAsync("SELECT id, value FROM t ORDER BY id", Ct); + List rows = await result.ToListAsync(Ct); + Assert.Equal(delete ? new long[] { long.MinValue, long.MaxValue } : [long.MinValue, -1, 0, long.MaxValue], + rows.Select(row => row[0].AsInteger)); + Assert.Equal(delete ? new long[] { 1, 4 } : [1, 12, 13, 4], rows.Select(row => row[1].AsInteger)); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task BackedPrimaryKey_UsesLogicalKeyInsteadOfHiddenRowId(bool delete, bool composite) + { + await using var db = await Database.OpenInMemoryAsync(Ct); + await db.ExecuteAsync(composite + ? "CREATE TABLE t (id INTEGER, value INTEGER, PRIMARY KEY (id, value))" + : "CREATE TABLE t (id SMALLINT PRIMARY KEY, value INTEGER)", Ct); + await db.ExecuteAsync(composite ? "INSERT INTO t VALUES (11, 100), (11, 200), (22, 300)" + : "INSERT INTO t VALUES (11, 100), (22, 300)", Ct); + await using var changed = await db.ExecuteAsync((delete ? "DELETE FROM t" : "UPDATE t SET value = value + 1") + + " WHERE id >= 10 AND id < 20", Ct); + Assert.Equal(composite ? 2 : 1, changed.RowsAffected); + await using var result = await db.ExecuteAsync("SELECT id, value FROM t ORDER BY id, value", Ct); + List rows = await result.ToListAsync(Ct); + Assert.Equal(delete ? new long[] { 22 } : composite ? [11, 11, 22] : [11, 22], rows.Select(row => row[0].AsInteger)); + Assert.Equal(delete ? new long[] { 300 } : composite ? [101, 201, 300] : [101, 300], rows.Select(row => row[1].AsInteger)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SerializableMutation_EmptyRangeStillDetectsPhantom(bool delete) + { + await using var db = await Database.OpenInMemoryAsync(Ct); + await db.ExecuteAsync("CREATE TABLE t (id INTEGER PRIMARY KEY, value INTEGER)", Ct); + await db.ExecuteAsync("INSERT INTO t VALUES (30, 300)", Ct); + await using var transaction = await db.BeginWriteTransactionAsync(Ct); + await using var result = await transaction.ExecuteAsync( + (delete ? "DELETE FROM t" : "UPDATE t SET value = value + 1") + " WHERE id >= 10 AND id < 20", Ct); + Assert.Equal(0, result.RowsAffected); + await db.ExecuteAsync("INSERT INTO t VALUES (12, 120)", Ct); + await Assert.ThrowsAsync(() => transaction.CommitAsync(Ct).AsTask()); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task Explain_ReportsBoundedMutationAndAnalyzePreservesRowIdentity(bool delete, bool analyze) + { + await using var db = await CreateAsync(); + string mutation = (delete ? "DELETE FROM t" : "UPDATE t SET value = value + 1") + + " WHERE id >= 40 AND id < 44"; + await using var result = await db.ExecuteAsync($"EXPLAIN {(analyze ? "ANALYZE " : "")}{mutation}", Ct); + List plan = await result.ToListAsync(Ct); + DbValue[] range = Assert.Single(plan, row => !row[8].IsNull && row[8].AsText == "ordered_index"); + Assert.Equal("t", range[9].AsText); + Assert.DoesNotContain(plan, row => row[2].AsText == "table_scan"); + if (analyze) + Assert.Equal(4, range[5].AsInteger); + else + Assert.True(range[5].IsNull); + List rows = await RowsAsync(db); + Assert.Equal(analyze && delete ? 124 : 128, rows.Count); + foreach (DbValue[] row in rows) + { + long id = row[0].AsInteger; + Assert.Equal(id * 10 + (analyze && !delete && id >= 40 && id < 44 ? 1 : 0), row[1].AsInteger); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task NonLeadingPrimaryKeyAndAlteredSchema_PreserveFullRowShape(bool delete) + { + await using var db = await Database.OpenInMemoryAsync(Ct); + await db.ExecuteAsync("CREATE TABLE t (value INTEGER, id INTEGER PRIMARY KEY)", Ct); + await db.ExecuteAsync("INSERT INTO t VALUES (100, 11), (200, 12), (300, 22)", Ct); + await db.ExecuteAsync("ALTER TABLE t ADD COLUMN extra TEXT", Ct); + await using var changed = await db.ExecuteAsync((delete ? "DELETE FROM t" : "UPDATE t SET value = value + 1") + + " WHERE id >= 10 AND id < 20", Ct); + Assert.Equal(2, changed.RowsAffected); + await using var result = await db.ExecuteAsync("SELECT * FROM t ORDER BY id", Ct); + List rows = await result.ToListAsync(Ct); + Assert.Equal(delete ? new long[] { 22 } : [11, 12, 22], rows.Select(row => row[1].AsInteger)); + Assert.Equal(delete ? new long[] { 300 } : [101, 201, 300], rows.Select(row => row[0].AsInteger)); + Assert.All(rows, row => Assert.True(row[2].IsNull)); + } + + private static async Task CreateAsync() + { + var db = await Database.OpenInMemoryAsync(Ct); + await db.ExecuteAsync("CREATE TABLE t (id INTEGER PRIMARY KEY, value INTEGER, payload TEXT)", Ct); + await db.ExecuteAsync("INSERT INTO t VALUES " + string.Join(',', Enumerable.Range(0, 128) + .Select(i => $"({i}, {i * 10}, 'payload-{i}')")), Ct); + return db; + } + + private static async Task> RowsAsync(Database db) + { + await using var result = await db.ExecuteAsync("SELECT id, value, payload FROM t ORDER BY id", Ct); + return await result.ToListAsync(Ct); + } +} diff --git a/tests/CSharpDB.Tests/PrimaryKeyRangeScanOperatorTests.cs b/tests/CSharpDB.Tests/PrimaryKeyRangeScanOperatorTests.cs new file mode 100644 index 00000000..916e65f4 --- /dev/null +++ b/tests/CSharpDB.Tests/PrimaryKeyRangeScanOperatorTests.cs @@ -0,0 +1,281 @@ +using CSharpDB.Execution; +using CSharpDB.Primitives; +using CSharpDB.Storage.BTrees; +using CSharpDB.Storage.Checkpointing; +using CSharpDB.Storage.Device; +using CSharpDB.Storage.Indexing; +using CSharpDB.Storage.Paging; +using CSharpDB.Storage.Serialization; +using CSharpDB.Storage.Wal; + +namespace CSharpDB.Tests; + +[Collection("StorageConcurrency")] +public sealed class PrimaryKeyRangeScanOperatorTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task InclusiveAndExclusiveBounds_AgreeInRowAndBatchModes(bool lowerInclusive, bool upperInclusive) + { + long[] keys = [long.MinValue, -100, -3, -1, 0, 2, 5, 100, long.MaxValue]; + await using var fixture = await Fixture.CreateAsync(keys); + var range = new IndexScanRange(-3, lowerInclusive, 5, upperInclusive); + long[] expected = keys.Where(key => (lowerInclusive ? key >= -3 : key > -3) && + (upperInclusive ? key <= 5 : key < 5)).ToArray(); + await using var op = fixture.Create(range, [0]); + Assert.Equal(expected, (await ReadRowsAsync(op)).Select(row => row[0].AsInteger)); + Assert.Equal(expected, (await ReadBatchesAsync(op)).Select(row => row[0].AsInteger)); + } + + [Fact] + public async Task UnboundedMissingAndExtremeBounds_DoNotOverflowOrRestartAfterExhaustion() + { + long[] keys = [long.MinValue, -5, 0, 5, long.MaxValue]; + await using var fixture = await Fixture.CreateAsync(keys); + IndexScanRange[] ranges = + [ + IndexScanRange.All, + new(long.MinValue, true, long.MaxValue, true), + new(long.MinValue, false, long.MaxValue, false), + new(long.MaxValue, false, null, true), + new(null, true, long.MinValue, false), + new(null, true, 0, true), + new(0, false, null, true), + new(1, true, 6, false), + new(5, false, 5, true), + new(5, true, -5, true), + ]; + foreach (IndexScanRange range in ranges) + { + long[] expected = keys.Where(key => + (!range.LowerBound.HasValue || (range.LowerInclusive ? key >= range.LowerBound.Value : key > range.LowerBound.Value)) && + (!range.UpperBound.HasValue || (range.UpperInclusive ? key <= range.UpperBound.Value : key < range.UpperBound.Value))).ToArray(); + await using var op = fixture.Create(range, [0]); + Assert.Equal(expected, (await ReadRowsAsync(op)).Select(row => row[0].AsInteger)); + Assert.False(await op.MoveNextAsync(Ct)); + Assert.False(await op.MoveNextAsync(Ct)); + Assert.Equal(expected, (await ReadBatchesAsync(op)).Select(row => row[0].AsInteger)); + Assert.False(await op.MoveNextBatchAsync(Ct)); + } + } + + [Fact] + public async Task SeekAcrossLeafPages_DecodesOnlyRowsWithinBounds() + { + long[] keys = Enumerable.Range(-96, 193).Select(i => 3L * i).ToArray(); + await using var fixture = await Fixture.CreateAsync(keys, paddingBytes: 1024); + var rootPage = await fixture.Pager.GetPageAsync(fixture.Tree.RootPageId, Ct); + Assert.Equal(PageConstants.PageTypeInterior, new SlottedPage(rootPage, fixture.Tree.RootPageId).PageType); + var serializer = new TrackingSerializer(); + await using var op = fixture.Create(new IndexScanRange(5, true, 251, false), null, serializer); + long[] expected = keys.Where(key => key >= 5 && key < 251).ToArray(); + + DbValue[][] rows = await ReadRowsAsync(op); + Assert.Equal(expected, rows.Select(row => row[0].AsInteger)); + Assert.All(rows, row => Assert.StartsWith($"{row[0].AsInteger}:", row[2].AsText)); + Assert.Equal(expected.Length, serializer.DecodeCalls); + DbValue[][] batches = await ReadBatchesAsync(op); + Assert.Equal(expected, batches.Select(row => row[0].AsInteger)); + Assert.Equal(2 * expected.Length, serializer.DecodeCalls); + } + + [Fact] + public async Task ProjectionAndStar_PadOlderRowsAndPreserveRepeatedOrdinals() + { + await using var fixture = await Fixture.CreateAsync([1, 2, 3], alternateShortRows: true); + await using (var star = fixture.Create(IndexScanRange.All, null)) + { + DbValue[][] rows = await ReadRowsAsync(star); + Assert.Equal("1:", rows[0][2].AsText); + Assert.True(rows[1][2].IsNull); + Assert.Equal("3:", rows[2][2].AsText); + } + + await using (var projected = fixture.Create(IndexScanRange.All, [2, 0, 2])) + { + DbValue[][] rows = await ReadRowsAsync(projected); + Assert.Equal(new long[] { 1, 2, 3 }, rows.Select(row => row[1].AsInteger)); + Assert.Equal("1:", rows[0][0].AsText); + Assert.Equal(rows[0][0], rows[0][2]); + Assert.True(rows[1][0].IsNull); + Assert.True(rows[1][2].IsNull); + Assert.Equal("3:", rows[2][0].AsText); + DbValue[][] batches = await ReadBatchesAsync(projected); + for (int i = 0; i < rows.Length; i++) Assert.Equal(rows[i], batches[i]); + } + + var serializer = new TrackingSerializer(); + await using var prefix = fixture.Create(IndexScanRange.All, [1], serializer); + Assert.Equal(new long[] { 10, 20, 30 }, (await ReadRowsAsync(prefix)).Select(row => row[0].AsInteger)); + Assert.InRange(serializer.MaxDestinationColumns, 1, 2); + } + + [Fact] + public async Task PrimaryKeyOnlyProjection_UsesCursorKeysWithoutRecordDecoding() + { + await using var fixture = await Fixture.CreateAsync([long.MinValue, 0, long.MaxValue]); + var serializer = new TrackingSerializer { RejectDecoding = true }; + await using var op = fixture.Create(IndexScanRange.All, [0, 0], serializer); + DbValue[][] rows = await ReadRowsAsync(op); + Assert.Equal(new long[] { long.MinValue, 0, long.MaxValue }, rows.Select(row => row[0].AsInteger)); + Assert.All(rows, row => Assert.Equal(row[0], row[1])); + Assert.Equal(3, (await ReadBatchesAsync(op)).Length); + Assert.Equal(0, serializer.DecodeCalls); + } + + [Fact] + public async Task ReopeningAndChangingReuseModes_PreservePreviouslyOwnedRowsAndBatches() + { + long[] keys = Enumerable.Range(0, 140).Select(i => (long)i).ToArray(); + await using var fixture = await Fixture.CreateAsync(keys); + await using var op = fixture.Create(IndexScanRange.All, [0]); + Assert.Equal(0, op.CurrentBatch.Capacity); + op.SetReuseCurrentRowBuffer(false); + await op.OpenAsync(Ct); + Assert.Equal(0, op.CurrentBatch.Capacity); + Assert.True(await op.MoveNextAsync(Ct)); + DbValue[] firstRow = op.Current; + Assert.True(await op.MoveNextAsync(Ct)); + Assert.NotSame(firstRow, op.Current); + op.SetReuseCurrentRowBuffer(true); + Assert.True(await op.MoveNextAsync(Ct)); + Assert.Equal(0L, firstRow[0].AsInteger); + + op.SetReuseCurrentBatch(false); + await op.OpenAsync(Ct); + Assert.True(await op.MoveNextBatchAsync(Ct)); + RowBatch firstBatch = op.CurrentBatch; + Assert.True(await op.MoveNextBatchAsync(Ct)); + RowBatch secondBatch = op.CurrentBatch; + Assert.NotSame(firstBatch, secondBatch); + op.SetReuseCurrentBatch(true); + Assert.True(await op.MoveNextBatchAsync(Ct)); + Assert.NotSame(secondBatch, op.CurrentBatch); + Assert.Equal(12, op.CurrentBatch.Count); + Assert.False(await op.MoveNextBatchAsync(Ct)); + Assert.Equal(64, firstBatch.Count); + Assert.Equal(0L, firstBatch.GetRowSpan(0)[0].AsInteger); + Assert.Equal(63L, firstBatch.GetRowSpan(63)[0].AsInteger); + Assert.Equal(64, secondBatch.Count); + Assert.Equal(64L, secondBatch.GetRowSpan(0)[0].AsInteger); + Assert.Equal(127L, secondBatch.GetRowSpan(63)[0].AsInteger); + + await op.DisposeAsync(); + Assert.Equal(keys, (await ReadRowsAsync(op)).Select(row => row[0].AsInteger)); + Assert.Equal(keys, (await ReadBatchesAsync(op)).Select(row => row[0].AsInteger)); + } + + private static async Task ReadRowsAsync(PrimaryKeyRangeScanOperator op) + { + var rows = new List(); + await op.OpenAsync(Ct); + while (await op.MoveNextAsync(Ct)) rows.Add((DbValue[])op.Current.Clone()); + return rows.ToArray(); + } + + private static async Task ReadBatchesAsync(PrimaryKeyRangeScanOperator op) + { + var rows = new List(); + await op.OpenAsync(Ct); + while (await op.MoveNextBatchAsync(Ct)) + for (int i = 0; i < op.CurrentBatch.Count; i++) rows.Add(op.CurrentBatch.GetRowSpan(i).ToArray()); + return rows.ToArray(); + } + + private sealed class Fixture(Pager pager, BTree tree, TableSchema schema) : IAsyncDisposable + { + public Pager Pager { get; } = pager; + public BTree Tree { get; } = tree; + + public static async Task CreateAsync(long[] keys, int paddingBytes = 0, bool alternateShortRows = false) + { + var walIndex = new WalIndex(); + var pager = await Pager.CreateAsync(new MemoryStorageDevice(), new MemoryWriteAheadLog(walIndex), walIndex, + new PagerOptions { CheckpointPolicy = new FrameCountCheckpointPolicy(1_000_000), MaxCachedPages = 32 }, Ct); + try + { + await pager.InitializeNewDatabaseAsync(Ct); + await pager.RecoverAsync(Ct); + await pager.BeginTransactionAsync(Ct); + var tree = new BTree(pager, await BTree.CreateNewAsync(pager, Ct), "range_items"); + var serializer = new DefaultRecordSerializer(); + for (int i = 0; i < keys.Length; i++) + { + DbValue[] row = alternateShortRows && i % 2 == 1 + ? [DbValue.FromInteger(keys[i]), DbValue.FromInteger((i + 1) * 10)] + : [DbValue.FromInteger(keys[i]), DbValue.FromInteger((i + 1) * 10), + DbValue.FromText($"{keys[i]}:{new string('x', paddingBytes)}")]; + await tree.InsertAsync(keys[i], serializer.Encode(row), Ct); + } + await pager.CommitAsync(Ct); + return new Fixture(pager, tree, new TableSchema + { + TableName = "range_items", + Columns = + [ + new ColumnDefinition { Name = "id", Type = DbType.Integer, IsPrimaryKey = true, Nullable = false }, + new ColumnDefinition { Name = "value", Type = DbType.Integer }, + new ColumnDefinition { Name = "payload", Type = DbType.Text }, + ], + }); + } + catch + { + await pager.DisposeAsync(); + throw; + } + } + + public PrimaryKeyRangeScanOperator Create(IndexScanRange range, int[]? projection, IRecordSerializer? serializer = null) + => new(Tree, schema, range, serializer ?? new DefaultRecordSerializer(), projection, + projection is null ? schema.Columns.ToArray() : projection.Select(column => schema.Columns[column]).ToArray()); + + public ValueTask DisposeAsync() => Pager.DisposeAsync(); + } + + private sealed class TrackingSerializer : IRecordSerializer + { + private readonly DefaultRecordSerializer _inner = new(); + public bool RejectDecoding { get; init; } + public int DecodeCalls { get; private set; } + public int MaxDestinationColumns { get; private set; } + public byte[] Encode(ReadOnlySpan values) => _inner.Encode(values); + + private void RecordDecode() + { + if (RejectDecoding) throw new InvalidOperationException("Key-only projection must not decode records."); + DecodeCalls++; + } + + public DbValue[] Decode(ReadOnlySpan buffer) { RecordDecode(); return _inner.Decode(buffer); } + public int DecodeInto(ReadOnlySpan buffer, Span destination) + { + RecordDecode(); + MaxDestinationColumns = Math.Max(MaxDestinationColumns, destination.Length); + return _inner.DecodeInto(buffer, destination); + } + public void DecodeSelectedInto(ReadOnlySpan buffer, Span destination, ReadOnlySpan columns) + { RecordDecode(); _inner.DecodeSelectedInto(buffer, destination, columns); } + public void DecodeSelectedCompactInto(ReadOnlySpan buffer, Span destination, ReadOnlySpan columns) + { + RecordDecode(); + MaxDestinationColumns = Math.Max(MaxDestinationColumns, destination.Length); + _inner.DecodeSelectedCompactInto(buffer, destination, columns); + } + public DbValue[] DecodeUpTo(ReadOnlySpan buffer, int maxColumnIndexInclusive) + { RecordDecode(); return _inner.DecodeUpTo(buffer, maxColumnIndexInclusive); } + public DbValue DecodeColumn(ReadOnlySpan buffer, int columnIndex) + { RecordDecode(); return _inner.DecodeColumn(buffer, columnIndex); } + public bool TryColumnTextEquals(ReadOnlySpan buffer, int columnIndex, ReadOnlySpan expectedUtf8, out bool equals) + { RecordDecode(); return _inner.TryColumnTextEquals(buffer, columnIndex, expectedUtf8, out equals); } + public bool IsColumnNull(ReadOnlySpan buffer, int columnIndex) + { RecordDecode(); return _inner.IsColumnNull(buffer, columnIndex); } + public bool TryDecodeNumericColumn(ReadOnlySpan buffer, int columnIndex, out long intValue, out double realValue, out bool isReal) + { RecordDecode(); return _inner.TryDecodeNumericColumn(buffer, columnIndex, out intValue, out realValue, out isReal); } + } +} diff --git a/tests/CSharpDB.Tests/PrimaryKeySparseProjectionTests.cs b/tests/CSharpDB.Tests/PrimaryKeySparseProjectionTests.cs new file mode 100644 index 00000000..c65a98d3 --- /dev/null +++ b/tests/CSharpDB.Tests/PrimaryKeySparseProjectionTests.cs @@ -0,0 +1,230 @@ +using CSharpDB.Execution; +using CSharpDB.Primitives; +using CSharpDB.Storage.BTrees; +using CSharpDB.Storage.Checkpointing; +using CSharpDB.Storage.Device; +using CSharpDB.Storage.Indexing; +using CSharpDB.Storage.Paging; +using CSharpDB.Storage.Serialization; +using CSharpDB.Storage.Wal; + +namespace CSharpDB.Tests; + +[Collection("StorageConcurrency")] +public sealed class PrimaryKeySparseProjectionTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SparseIntegerProjection_ResetsMissingAndNullColumnsAcrossBatchesAndReopen(bool batches) + { + await using var fixture = await Fixture.CreateAsync(130, alternateShortRows: true); + await using var op = fixture.Create([0, 3]); + for (int traversal = 0; traversal < 2; traversal++) + { + DbValue[][] rows = await ReadAsync(op, batches); + Assert.Equal(130, rows.Length); + for (int i = 0; i < rows.Length; i++) + { + Assert.Equal(i, rows[i][0].AsInteger); + if (i % 3 == 0) + Assert.Equal(i * 10L, rows[i][1].AsInteger); + else + Assert.True(rows[i][1].IsNull); + } + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SparseTextAndBlobProjection_RetainsOwnedOutputsAfterMovesAndDisposal(bool batches) + { + await using var fixture = await Fixture.CreateAsync(130); + var op = fixture.Create([0, 4, 5]); + var retainedRows = new List(); + var retainedBatches = new List(); + await using (op) + { + op.SetReuseCurrentRowBuffer(false); + op.SetReuseCurrentBatch(false); + await op.OpenAsync(Ct); + if (batches) + { + while (await op.MoveNextBatchAsync(Ct)) + retainedBatches.Add(op.CurrentBatch); + Assert.Equal(new[] { 64, 64, 2 }, retainedBatches.Select(batch => batch.Count)); + } + else + { + while (await op.MoveNextAsync(Ct)) + retainedRows.Add(op.Current); + } + } + + foreach (RowBatch batch in retainedBatches) + for (int row = 0; row < batch.Count; row++) + retainedRows.Add(batch.GetRowSpan(row).ToArray()); + + Assert.Equal(130, retainedRows.Count); + for (int i = 0; i < retainedRows.Count; i++) + { + Assert.Equal(i, retainedRows[i][0].AsInteger); + Assert.Equal(Text(i), retainedRows[i][1].AsText); + Assert.Equal(Blob(i), retainedRows[i][2].AsBlob); + if (i > 0) + { + Assert.NotSame(retainedRows[i - 1], retainedRows[i]); + Assert.NotSame(retainedRows[i - 1][2].AsBlob, retainedRows[i][2].AsBlob); + } + } + retainedRows[0][2].AsBlob[0] = 255; + Assert.Equal(Blob(1), retainedRows[1][2].AsBlob); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SparseProjection_UsesConfiguredSerializerAndPadsShortCustomRecords(bool batches) + { + await using var fixture = await Fixture.CreateAsync(65, alternateShortRows: true, + serializer: new PrefixSerializer()); + await using var op = fixture.Create([1, 3, 5]); + DbValue[][] rows = await ReadAsync(op, batches); + Assert.Equal(65, rows.Length); + for (int i = 0; i < rows.Length; i++) + { + Assert.Equal($"unused:{i}:padding", rows[i][0].AsText); + if (i % 3 == 1) + { + Assert.True(rows[i][1].IsNull); + Assert.True(rows[i][2].IsNull); + } + else + { + if (i % 3 == 2) + Assert.True(rows[i][1].IsNull); + else + Assert.Equal(i * 10L, rows[i][1].AsInteger); + Assert.Equal(Blob(i), rows[i][2].AsBlob); + } + } + } + + private static string Text(int i) => $"retained:{i}:the selected text survives subsequent records"; + private static byte[] Blob(int i) => [(byte)i, (byte)(i >> 8), 17, 29, 43]; + + private static async Task ReadAsync(PrimaryKeyRangeScanOperator op, bool batches) + { + var rows = new List(); + await op.OpenAsync(Ct); + if (batches) + { + while (await op.MoveNextBatchAsync(Ct)) + for (int i = 0; i < op.CurrentBatch.Count; i++) + rows.Add(op.CurrentBatch.GetRowSpan(i).ToArray()); + } + else + { + while (await op.MoveNextAsync(Ct)) + rows.Add((DbValue[])op.Current.Clone()); + } + return rows.ToArray(); + } + + private sealed class Fixture(Pager pager, BTree tree, TableSchema schema, IRecordSerializer serializer) + : IAsyncDisposable + { + public static async Task CreateAsync(int count, bool alternateShortRows = false, + IRecordSerializer? serializer = null) + { + serializer ??= new DefaultRecordSerializer(); + var walIndex = new WalIndex(); + var pager = await Pager.CreateAsync(new MemoryStorageDevice(), new MemoryWriteAheadLog(walIndex), walIndex, + new PagerOptions { CheckpointPolicy = new FrameCountCheckpointPolicy(1_000_000) }, Ct); + try + { + await pager.InitializeNewDatabaseAsync(Ct); + await pager.RecoverAsync(Ct); + await pager.BeginTransactionAsync(Ct); + var tree = new BTree(pager, await BTree.CreateNewAsync(pager, Ct), "sparse_items"); + for (int i = 0; i < count; i++) + { + DbValue[] row = + [ + DbValue.FromInteger(i), DbValue.FromText($"unused:{i}:padding"), + DbValue.FromBlob(new byte[128]), + alternateShortRows && i % 3 == 2 ? DbValue.Null : DbValue.FromInteger(i * 10L), + DbValue.FromText(Text(i)), DbValue.FromBlob(Blob(i)), + ]; + if (alternateShortRows && i % 3 == 1) + row = row[..3]; + await tree.InsertAsync(i, serializer.Encode(row), Ct); + } + await pager.CommitAsync(Ct); + return new Fixture(pager, tree, new TableSchema + { + TableName = "sparse_items", + Columns = + [ + new() { Name = "id", Type = DbType.Integer, IsPrimaryKey = true, Nullable = false }, + new() { Name = "unused_text", Type = DbType.Text }, + new() { Name = "unused_blob", Type = DbType.Blob }, + new() { Name = "value", Type = DbType.Integer }, + new() { Name = "text", Type = DbType.Text }, + new() { Name = "blob", Type = DbType.Blob }, + ], + }, serializer); + } + catch + { + await pager.DisposeAsync(); + throw; + } + } + + public PrimaryKeyRangeScanOperator Create(int[] projection) + => new(tree, schema, IndexScanRange.All, serializer, projection, + projection.Select(column => schema.Columns[column]).ToArray()); + + public ValueTask DisposeAsync() => pager.DisposeAsync(); + } + + private sealed class PrefixSerializer : IRecordSerializer + { + private readonly DefaultRecordSerializer _inner = new(); + + public byte[] Encode(ReadOnlySpan values) + { + byte[] record = _inner.Encode(values); + byte[] prefixed = new byte[record.Length + 1]; + prefixed[0] = 0xF7; + record.CopyTo(prefixed.AsSpan(1)); + return prefixed; + } + + private static ReadOnlySpan Payload(ReadOnlySpan buffer) + { + if (buffer.IsEmpty || buffer[0] != 0xF7) + throw new InvalidDataException("Missing custom serializer prefix."); + return buffer[1..]; + } + + public DbValue[] Decode(ReadOnlySpan buffer) => _inner.Decode(Payload(buffer)); + public int DecodeInto(ReadOnlySpan buffer, Span destination) + => _inner.DecodeInto(Payload(buffer), destination); + public void DecodeSelectedInto(ReadOnlySpan buffer, Span destination, ReadOnlySpan columns) + => _inner.DecodeSelectedInto(Payload(buffer), destination, columns); + public void DecodeSelectedCompactInto(ReadOnlySpan buffer, Span destination, ReadOnlySpan columns) + => _inner.DecodeSelectedCompactInto(Payload(buffer), destination, columns); + public DbValue[] DecodeUpTo(ReadOnlySpan buffer, int maximum) => _inner.DecodeUpTo(Payload(buffer), maximum); + public DbValue DecodeColumn(ReadOnlySpan buffer, int column) => _inner.DecodeColumn(Payload(buffer), column); + public bool TryColumnTextEquals(ReadOnlySpan buffer, int column, ReadOnlySpan text, out bool equals) + => _inner.TryColumnTextEquals(Payload(buffer), column, text, out equals); + public bool IsColumnNull(ReadOnlySpan buffer, int column) => _inner.IsColumnNull(Payload(buffer), column); + public bool TryDecodeNumericColumn(ReadOnlySpan buffer, int column, out long integer, out double real, out bool isReal) + => _inner.TryDecodeNumericColumn(Payload(buffer), column, out integer, out real, out isReal); + } +} diff --git a/tests/CSharpDB.Tests/QueryPlannerLazyStateTests.cs b/tests/CSharpDB.Tests/QueryPlannerLazyStateTests.cs new file mode 100644 index 00000000..d91ad2ba --- /dev/null +++ b/tests/CSharpDB.Tests/QueryPlannerLazyStateTests.cs @@ -0,0 +1,209 @@ +using System.Reflection; +using CSharpDB.Engine; +using CSharpDB.Execution; +using CSharpDB.Primitives; +using CSharpDB.Sql; + +namespace CSharpDB.Tests; + +public sealed class QueryPlannerLazyStateTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + private const string JoinSql = + "SELECT l.id, r.payload FROM adaptive_left l JOIN adaptive_right r ON l.code = r.code"; + + [Fact] + public async Task ObserverInstalledBeforeFirstAdaptiveQuery_ReceivesPlanningAndRuntimeEvents() + { + await using var db = await CreateAdaptiveDatabaseAsync(); + QueryPlanner planner = GetPlanner(db); + var observer = new RecordingObserver(); + planner.PlanRuntimeObserver = observer; + db.ResetSelectPlanCacheDiagnostics(); + db.ResetAdaptiveQueryReoptimizationDiagnostics(); + Statement statement = Parser.Parse(JoinSql); + + for (int execution = 0; execution < 2; execution++) + { + await using QueryResult result = await planner.ExecuteAsync(statement, Ct); + AssertJoinRows(await result.ToListAsync(Ct)); + } + + Assert.Equal(new[] { false, true }, observer.CacheLookups); + Assert.Equal(2, observer.Selections.Count); + Assert.Equal(2, observer.Changes.Count(change => + change == QueryPlanChangeKind.AdaptiveReoptimizationAttempted)); + var plans = db.GetSelectPlanCacheDiagnostics(); + Assert.Equal(1, plans.MissCount); + Assert.Equal(1, plans.HitCount); + var adaptive = db.GetAdaptiveQueryReoptimizationDiagnosticsSnapshot(); + Assert.Equal(2, adaptive.EligibleQueryCount); + Assert.Equal(2, adaptive.AttemptCount); + Assert.True(adaptive.BufferedRowCount > 0); + } + + [Fact] + public async Task ObserverChangedAfterPlanning_UpdatesExistingAdaptiveExecution() + { + await using var db = await CreateAdaptiveDatabaseAsync(); + QueryPlanner planner = GetPlanner(db); + var planningObserver = new RecordingObserver(); + var executionObserver = new RecordingObserver(); + planner.PlanRuntimeObserver = planningObserver; + Statement statement = Parser.Parse(JoinSql); + + await using (QueryResult result = await planner.ExecuteAsync(statement, Ct)) + { + Assert.Equal(new[] { false }, planningObserver.CacheLookups); + Assert.Single(planningObserver.Selections); + Assert.Empty(planningObserver.Changes); + + // The operator has captured its diagnostics object, but execution has not begun. + planner.PlanRuntimeObserver = executionObserver; + AssertJoinRows(await result.ToListAsync(Ct)); + } + + Assert.Empty(planningObserver.Changes); + Assert.Empty(executionObserver.CacheLookups); + Assert.Empty(executionObserver.Selections); + Assert.Equal(1, executionObserver.Changes.Count(change => + change == QueryPlanChangeKind.AdaptiveReoptimizationAttempted)); + int observedChangeCount = executionObserver.Changes.Count; + + await using (QueryResult result = await planner.ExecuteAsync(statement, Ct)) + { + Assert.Equal(new[] { true }, executionObserver.CacheLookups); + Assert.Single(executionObserver.Selections); + planner.PlanRuntimeObserver = null; + AssertJoinRows(await result.ToListAsync(Ct)); + } + + Assert.Equal(observedChangeCount, executionObserver.Changes.Count); + Assert.Equal(2, db.GetAdaptiveQueryReoptimizationDiagnosticsSnapshot().AttemptCount); + } + + [Fact] + public async Task SchemaChangesBeforeAndAfterQueries_RefreshExpressionsAndPreparedMetadata() + { + await using var db = await Database.OpenInMemoryAsync(Ct); + db.PreferSyncPointLookups = false; + var emptyPlans = db.GetSelectPlanCacheDiagnostics(); + Assert.Equal(0, emptyPlans.EntryCount); + db.ResetSelectPlanCacheDiagnostics(); + db.ResetAdaptiveQueryReoptimizationDiagnostics(); + Assert.Equal(emptyPlans, db.GetSelectPlanCacheDiagnostics()); + Assert.Equal(0, db.GetAdaptiveQueryReoptimizationDiagnosticsSnapshot().AttemptCount); + + await db.ExecuteAsync("CREATE TABLE unused (id INTEGER PRIMARY KEY)", Ct); + await db.ExecuteAsync("DROP TABLE unused", Ct); + await db.ExecuteAsync("CREATE TABLE items (id INTEGER PRIMARY KEY, value BIGINT)", Ct); + await db.ExecuteAsync("INSERT INTO items VALUES (1, 10)", Ct); + Statement expression = Parser.Parse( + "SELECT value - id AS delta FROM items WHERE value >= 10 ORDER BY id"); + var prepared = Assert.IsType(PreparedSelectTemplate.TryCreate( + Parser.Parse("SELECT value FROM items WHERE id = @id"), ["id"])); + + for (int execution = 0; execution < 2; execution++) + { + Assert.Equal(new long[] { 9 }, await ReadFirstColumnAsync(db, expression)); + Assert.Equal(new long[] { 10 }, await ReadFirstColumnAsync(db, prepared.Bind([1]))); + } + var populatedPlans = db.GetSelectPlanCacheDiagnostics(); + Assert.Equal(2, populatedPlans.MissCount); + Assert.Equal(2, populatedPlans.HitCount); + Assert.Equal(2, populatedPlans.EntryCount); + + await db.BeginTransactionAsync(Ct); + await db.ExecuteAsync("DROP TABLE items", Ct); + await db.ExecuteAsync("CREATE TABLE items (value BIGINT, id INTEGER PRIMARY KEY)", Ct); + await db.ExecuteAsync("INSERT INTO items VALUES (100, 2)", Ct); + Assert.Equal(new long[] { 98 }, await ReadFirstColumnAsync(db, expression)); + Assert.Equal(new long[] { 100 }, await ReadFirstColumnAsync(db, prepared.Bind([2]))); + Assert.Empty(await ReadFirstColumnAsync(db, prepared.Bind([1]))); + + await db.RollbackAsync(Ct); + Assert.Equal(new long[] { 9 }, await ReadFirstColumnAsync(db, expression)); + Assert.Equal(new long[] { 10 }, await ReadFirstColumnAsync(db, prepared.Bind([1]))); + Assert.Empty(await ReadFirstColumnAsync(db, prepared.Bind([2]))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ReplacedTrigger_UsesNewBodyAndPreservesGeneratedRowIds(bool parsedInsert) + { + await using var db = await Database.OpenInMemoryAsync(Ct); + async Task InsertAsync(string sql) + { + await using QueryResult insert = parsedInsert + ? await db.ExecuteAsync(Assert.IsType(Parser.Parse(sql)), Ct) + : await db.ExecuteAsync(sql, Ct); + Assert.Equal(1, insert.RowsAffected); + } + + await db.ExecuteAsync("CREATE TABLE items (id INTEGER PRIMARY KEY, value BIGINT)", Ct); + await db.ExecuteAsync("CREATE TABLE audit (id INTEGER PRIMARY KEY, value BIGINT)", Ct); + await db.ExecuteAsync( + "CREATE TRIGGER items_ai AFTER INSERT ON items " + + "BEGIN INSERT INTO audit (value) VALUES (NEW.value + 1); END", Ct); + await InsertAsync("INSERT INTO items VALUES (1, 10)"); + await InsertAsync("INSERT INTO items VALUES (2, 20)"); + + await db.ExecuteAsync("DROP TRIGGER items_ai", Ct); + await db.ExecuteAsync( + "CREATE TRIGGER ITEMS_AI AFTER INSERT ON items " + + "BEGIN INSERT INTO audit (value) VALUES (NEW.value + 100); END", Ct); + await InsertAsync("INSERT INTO items VALUES (3, 30)"); + await db.ExecuteAsync("DROP TRIGGER items_ai", Ct); + await InsertAsync("INSERT INTO items VALUES (4, 40)"); + + await using QueryResult result = await db.ExecuteAsync("SELECT id, value FROM audit ORDER BY id", Ct); + List rows = await result.ToListAsync(Ct); + Assert.Equal(new long[] { 1, 2, 3 }, rows.Select(row => row[0].AsInteger)); + Assert.Equal(new long[] { 11, 21, 130 }, rows.Select(row => row[1].AsInteger)); + } + + private static async Task CreateAdaptiveDatabaseAsync() + { + Database db = await Database.OpenInMemoryAsync( + new DatabaseOptions().EnableAdaptiveQueryReoptimization(builder => builder + .WithDivergenceFactor(2) + .WithMinimumObservedRows(1) + .WithMaxBufferedRows(64)), Ct); + await db.ExecuteAsync( + "CREATE TABLE adaptive_left (id INTEGER PRIMARY KEY, code INTEGER NOT NULL)", Ct); + await db.ExecuteAsync( + "CREATE TABLE adaptive_right (id INTEGER PRIMARY KEY, code INTEGER NOT NULL, payload INTEGER NOT NULL)", Ct); + await db.ExecuteAsync("INSERT INTO adaptive_left VALUES (1, 1), (2, 2), (3, 3)", Ct); + await db.ExecuteAsync("INSERT INTO adaptive_right VALUES (1, 1, 10), (2, 2, 20), (3, 3, 30)", Ct); + return db; + } + + private static QueryPlanner GetPlanner(Database db) + => Assert.IsType(typeof(Database) + .GetField("_planner", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(db)); + + private static void AssertJoinRows(List rows) + { + DbValue[][] ordered = rows.OrderBy(row => row[0].AsInteger).ToArray(); + Assert.Equal(new long[] { 1, 2, 3 }, ordered.Select(row => row[0].AsInteger)); + Assert.Equal(new long[] { 10, 20, 30 }, ordered.Select(row => row[1].AsInteger)); + } + + private static async Task ReadFirstColumnAsync(Database db, Statement statement) + { + await using QueryResult result = await db.ExecuteAsync(statement, Ct); + return (await result.ToListAsync(Ct)).Select(row => row[0].AsInteger).ToArray(); + } + + private sealed class RecordingObserver : IQueryPlanRuntimeObserver + { + internal List CacheLookups { get; } = []; + internal List Selections { get; } = []; + internal List Changes { get; } = []; + + public void OnPlanCacheLookup(bool hit) => CacheLookups.Add(hit); + public void OnAccessPathSelected(in QueryPlanRuntimeSelection selection) => Selections.Add(selection); + public void OnPlanChanged(QueryPlanChangeKind change) => Changes.Add(change); + } +} diff --git a/tests/CSharpDB.Tests/QueryResultBufferedRowTests.cs b/tests/CSharpDB.Tests/QueryResultBufferedRowTests.cs new file mode 100644 index 00000000..d45a2c2e --- /dev/null +++ b/tests/CSharpDB.Tests/QueryResultBufferedRowTests.cs @@ -0,0 +1,346 @@ +using CSharpDB.Execution; +using CSharpDB.Primitives; + +namespace CSharpDB.Tests; + +public sealed class QueryResultBufferedRowTests +{ + private static CancellationToken TestToken => TestContext.Current.CancellationToken; + + [Fact] + public async Task MoveNext_TraversesEmptyBatchesAndBoundariesExactlyOnce() + { + var source = new BatchSource(0, 64, 0, 64, 2, 0); + await using var result = QueryResult.FromBatchOperator(source); + for (int expected = 1; expected <= 130; expected++) + { + Assert.True(await result.MoveNextAsync(TestToken)); + AssertRow(result.Current, expected); + } + + Assert.False(await result.MoveNextAsync(TestToken)); + int getters = source.GetterCalls; + int refills = source.RefillCalls; + Assert.False(await result.MoveNextAsync(TestToken)); + Assert.Equal(getters, source.GetterCalls); + Assert.Equal(refills, source.RefillCalls); + Assert.Throws(() => result.Current); + Assert.Equal(1, source.OpenCalls); + } + + [Fact] + public async Task MoveNext_ReadsCurrentBatchOncePerBufferedRowAndOnceAfterRefill() + { + var source = new BatchSource(2, 2); + await using var result = QueryResult.FromBatchOperator(source); + Assert.Equal(0, source.GetterCalls); + foreach (int expectedCalls in new[] { 2, 3, 5, 6 }) + { + Assert.True(await result.MoveNextAsync(TestToken)); + Assert.Equal(expectedCalls, source.GetterCalls); + } + Assert.False(await result.MoveNextAsync(TestToken)); + Assert.Equal(7, source.GetterCalls); + Assert.Equal(3, source.RefillCalls); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task MoveNext_AwaitsSuspendedOpenOrRefill(bool opening) + { + var source = new BatchSource(1, 1); + var gate = NewGate(); + await using var result = QueryResult.FromBatchOperator(source); + if (opening) + source.OpenGate = gate; + else + { + Assert.True(await result.MoveNextAsync(TestToken)); + source.RefillGate = gate; + } + + ValueTask pending = result.MoveNextAsync(TestToken); + Assert.False(pending.IsCompleted); + gate.SetResult(true); + Assert.True(await pending.AsTask().WaitAsync(TimeSpan.FromSeconds(10), TestToken)); + AssertRow(result.Current, opening ? 1 : 2); + Assert.Equal(1, source.OpenCalls); + } + + [Fact] + public async Task BufferedGetterFault_IsDeliveredAsFaultedAwaitableAndRetryKeepsNextRow() + { + var source = new BatchSource(2); + await using var result = QueryResult.FromBatchOperator(source); + Assert.True(await result.MoveNextAsync(TestToken)); + var error = new IOException("controlled current batch failure"); + source.GetterError = error; + + ValueTask move = result.MoveNextAsync(TestToken); + Task task = move.AsTask(); + Assert.Same(error, await Assert.ThrowsAsync(() => task)); + Assert.True(task.IsFaulted); + AssertRow(result.Current, 1); + Assert.True(await result.MoveNextAsync(TestToken)); + AssertRow(result.Current, 2); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task BufferedGetterCancellation_PreservesExceptionAndCanceledTask(bool cancelToken) + { + using var cancellation = new CancellationTokenSource(); + if (cancelToken) + cancellation.Cancel(); + var source = new BatchSource(2); + await using var result = QueryResult.FromBatchOperator(source); + Assert.True(await result.MoveNextAsync(TestToken)); + var error = new OperationCanceledException("controlled cancellation", cancellation.Token); + source.GetterError = error; + + ValueTask move = result.MoveNextAsync(TestToken); + Task task = move.AsTask(); + Assert.Same(error, await Assert.ThrowsAnyAsync(() => task)); + Assert.True(task.IsCanceled); + Assert.True(await result.MoveNextAsync(TestToken)); + AssertRow(result.Current, 2); + } + + [Fact] + public async Task CanceledCallerToken_AllowsBufferedRowButCancelsRefillAndCanRetry() + { + var source = new BatchSource(2, 1); + await using var result = QueryResult.FromBatchOperator(source); + Assert.True(await result.MoveNextAsync(TestToken)); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + Assert.True(await result.MoveNextAsync(cancellation.Token)); + AssertRow(result.Current, 2); + Task task = result.MoveNextAsync(cancellation.Token).AsTask(); + await Assert.ThrowsAnyAsync(() => task); + Assert.True(task.IsCanceled); + Assert.True(await result.MoveNextAsync(TestToken)); + AssertRow(result.Current, 3); + Assert.False(await result.MoveNextAsync(TestToken)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task SuspendedRefill_CancellationOrFaultAllowsRetryWithoutSkipping(bool cancel) + { + var source = new BatchSource(1, 1); + await using var result = QueryResult.FromBatchOperator(source); + Assert.True(await result.MoveNextAsync(TestToken)); + var gate = source.RefillGate = NewGate(); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestToken); + Task task = result.MoveNextAsync(cancellation.Token).AsTask(); + Assert.False(task.IsCompleted); + if (cancel) + { + cancellation.Cancel(); + await Assert.ThrowsAnyAsync( + () => task.WaitAsync(TimeSpan.FromSeconds(10), TestToken)); + Assert.True(task.IsCanceled); + gate.SetResult(true); + } + else + { + var error = new IOException("controlled refill failure"); + gate.SetException(error); + Assert.Same(error, await Assert.ThrowsAsync( + () => task.WaitAsync(TimeSpan.FromSeconds(10), TestToken))); + } + + AssertRow(result.Current, 1); + Assert.True(await result.MoveNextAsync(TestToken)); + AssertRow(result.Current, 2); + Assert.False(await result.MoveNextAsync(TestToken)); + } + + [Fact] + public async Task CurrentReusesWorkingRow_EnumerationRetainsIndependentRowsAcrossRefillsAndDisposal() + { + var source = new BatchSource(2, 0, 2, 1); + var result = QueryResult.FromBatchOperator(source); + List retained = []; + await using (result) + { + Assert.True(await result.MoveNextAsync(TestToken)); + DbValue[] working = result.Current; + Assert.True(await result.MoveNextAsync(TestToken)); + Assert.Same(working, result.Current); + AssertRow(working, 2); + await foreach (DbValue[] row in result.GetRowsAsync(TestToken)) + { + Assert.NotSame(working, row); + retained.Add(row); + } + } + + Assert.Equal(3, retained.Count); + for (int i = 0; i < retained.Count; i++) + AssertRow(retained[i], i + 3); + Assert.NotSame(retained[0], retained[1]); + Assert.NotSame(retained[1], retained[2]); + Assert.Equal(1, source.DisposeCalls); + } + + [Theory] + [InlineData(63)] + [InlineData(64)] + [InlineData(65)] + public async Task PartialToList_ReturnsOnlyRemainingOwnedRowsAcrossBoundary(int consumed) + { + var source = new BatchSource(64, 0, 64, 2); + var result = QueryResult.FromBatchOperator(source); + List rows; + await using (result) + { + for (int i = 1; i <= consumed; i++) + { + Assert.True(await result.MoveNextAsync(TestToken)); + AssertRow(result.Current, i); + } + DbValue[] working = result.Current; + rows = await result.ToListAsync(TestToken); + Assert.Equal(130 - consumed, rows.Count); + Assert.All(rows, row => Assert.NotSame(working, row)); + Assert.False(await result.MoveNextAsync(TestToken)); + Assert.Empty(await result.ToListAsync(TestToken)); + Assert.Throws(() => result.Current); + } + for (int i = 0; i < rows.Count; i++) + { + AssertRow(rows[i], consumed + i + 1); + if (i > 0) + Assert.NotSame(rows[i - 1], rows[i]); + } + } + + [Fact] + public async Task LateExecutionScope_AppliesToSubsequentRefillAndDisposal() + { + var source = new BatchSource(2, 1); + var result = QueryResult.FromBatchOperator(source); + int depth = 0; + int entered = 0; + int exited = 0; + await using (result) + { + Assert.True(await result.MoveNextAsync(TestToken)); + result.SetExecutionScopeFactory(() => + { + entered++; + depth++; + return new CallbackScope(() => { depth--; exited++; }); + }); + source.OnRefill = () => Assert.Equal(1, depth); + source.OnDispose = () => Assert.Equal(1, depth); + Assert.True(await result.MoveNextAsync(TestToken)); + AssertRow(result.Current, 2); + Assert.Equal(0, entered); + Assert.True(await result.MoveNextAsync(TestToken)); + AssertRow(result.Current, 3); + Assert.Equal(1, entered); + Assert.Equal(0, depth); + } + Assert.Equal(2, entered); + Assert.Equal(entered, exited); + Assert.Equal(0, depth); + } + + private static void AssertRow(DbValue[] row, int expected) + { + Assert.Equal(2, row.Length); + Assert.Equal(expected, row[0].AsInteger); + Assert.Equal(expected * 10L, row[1].AsInteger); + } + + private static TaskCompletionSource NewGate() => + new(TaskCreationOptions.RunContinuationsAsynchronously); + + private sealed class CallbackScope(Action callback) : IDisposable + { + public void Dispose() => callback(); + } + + private sealed class BatchSource(params int[] sizes) : IBatchOperator + { + private readonly RowBatch _batch = new(2, Math.Max(1, sizes.Max())); + private int _nextBatch; + private int _nextValue = 1; + public ColumnDefinition[] OutputSchema { get; } = + [ + new() { Name = "id", Type = DbType.Integer }, + new() { Name = "value", Type = DbType.Integer }, + ]; + public TaskCompletionSource? OpenGate { get; set; } + public TaskCompletionSource? RefillGate { get; set; } + public Exception? GetterError { get; set; } + public Action? OnRefill { get; set; } + public Action? OnDispose { get; set; } + public int GetterCalls { get; private set; } + public int OpenCalls { get; private set; } + public int RefillCalls { get; private set; } + public int DisposeCalls { get; private set; } + + public RowBatch CurrentBatch + { + get + { + GetterCalls++; + if (GetterError is { } error) + { + GetterError = null; + throw error; + } + return _batch; + } + } + + public async ValueTask OpenAsync(CancellationToken ct = default) + { + OpenCalls++; + ct.ThrowIfCancellationRequested(); + var gate = OpenGate; + OpenGate = null; + if (gate is not null) + await gate.Task.WaitAsync(ct); + _batch.Reset(); + _nextBatch = 0; + _nextValue = 1; + } + + public async ValueTask MoveNextBatchAsync(CancellationToken ct = default) + { + RefillCalls++; + OnRefill?.Invoke(); + ct.ThrowIfCancellationRequested(); + var gate = RefillGate; + RefillGate = null; + if (gate is not null) + await gate.Task.WaitAsync(ct); + if (_nextBatch == sizes.Length) + return false; + _batch.Reset(); + int count = sizes[_nextBatch++]; + for (int i = 0; i < count; i++) + { + int value = _nextValue++; + _batch.AppendRow([DbValue.FromInteger(value), DbValue.FromInteger(value * 10L)]); + } + return true; + } + + public ValueTask DisposeAsync() + { + DisposeCalls++; + OnDispose?.Invoke(); + return ValueTask.CompletedTask; + } + } +} diff --git a/tests/CSharpDB.Tests/ReaderSessionSnapshotCatalogTests.cs b/tests/CSharpDB.Tests/ReaderSessionSnapshotCatalogTests.cs new file mode 100644 index 00000000..09fe1d49 --- /dev/null +++ b/tests/CSharpDB.Tests/ReaderSessionSnapshotCatalogTests.cs @@ -0,0 +1,604 @@ +using System.Reflection; +using CSharpDB.Engine; +using CSharpDB.Execution; +using CSharpDB.Primitives; +using CSharpDB.Sql; +using CSharpDB.Storage.Catalog; +using CSharpDB.Storage.Checkpointing; +using CSharpDB.Storage.Paging; +using CSharpDB.Storage.StorageEngine; + +namespace CSharpDB.Tests; + +public sealed class ReaderSessionSnapshotCatalogTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + private static readonly string Payload = new('x', 128); + + [Fact] + public async Task WarmPointAndCountReads_SwitchTablesWithoutLeakingCachedMetadata() + { + await using var db = await Database.OpenInMemoryAsync(Ct); + await db.ExecuteAsync("CREATE TABLE a (id INTEGER PRIMARY KEY, label TEXT)", Ct); + await db.ExecuteAsync("CREATE TABLE b (label TEXT, id INTEGER PRIMARY KEY)", Ct); + await db.ExecuteAsync("INSERT INTO a VALUES (1, 'a')", Ct); + await db.ExecuteAsync("INSERT INTO b VALUES ('b', 1), ('second', 2)", Ct); + await db.ExecuteAsync("CREATE VIEW v AS SELECT id FROM b", Ct); + using var reader = db.CreateReaderSession(); + await db.ExecuteAsync("INSERT INTO a VALUES (2, 'later')", Ct); + await db.ExecuteAsync("DROP TABLE b", Ct); + + for (int i = 0; i < 3; i++) + { + Assert.Equal(1, Assert.Single((await ReadAsync(reader, "SELECT COUNT(*) FROM a")).Rows)[0].AsInteger); + Assert.Equal("a", Assert.Single((await ReadAsync(reader, "SELECT label FROM A WHERE id = 1")).Rows)[0].AsText); + Assert.Equal("b", Assert.Single((await ReadAsync(reader, "SELECT label FROM b WHERE id = 1")).Rows)[0].AsText); + Assert.Equal(2, Assert.Single((await ReadAsync(reader, "SELECT COUNT(*) FROM B")).Rows)[0].AsInteger); + Assert.Equal(2, Assert.Single((await ReadAsync(reader, "SELECT COUNT(*) FROM v")).Rows)[0].AsInteger); + } + } + + [Theory(Timeout = 30_000)] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task OrdinaryAndPreparedReads_KeepTableAndIndexRootsAfterSplits(bool fileBacked, bool warmReader) + { + CancellationToken ct = TestContext.Current.CancellationToken; + string path = Path.Combine(Path.GetTempPath(), $"csharpdb_snapshot_catalog_{Guid.NewGuid():N}.db"); + try + { + await using var db = fileBacked + ? await Database.OpenAsync(path, ct) + : await Database.OpenInMemoryAsync(ct); + await CreateTableAsync(db); + await InsertAsync(db, 0, 16); + SchemaCatalog catalog = GetField(db, "_catalog"); + uint tableRoot = catalog.GetTableRootPage("t"); + uint indexRoot = catalog.GetIndexStore("ix_t_k").RootPageId; + using var reader = db.CreateReaderSession(); + var primaryRange = Prepare("SELECT id FROM t WHERE id >= @lower AND id < @upper ORDER BY id"); + var indexRange = Prepare("SELECT id FROM t WHERE k >= @lower AND k < @upper ORDER BY k"); + if (warmReader) + { + Assert.Equal(16, (await ReadAsync(reader, primaryRange.Bind([0, 1024]))).Rows.Count); + Assert.Equal(16, (await ReadAsync(reader, indexRange.Bind([0, 10240]))).Rows.Count); + } + + await InsertAsync(db, 16, 1008); + Assert.NotEqual(tableRoot, catalog.GetTableRootPage("t")); + Assert.NotEqual(indexRoot, catalog.GetIndexStore("ix_t_k").RootPageId); + await db.ExecuteAsync("UPDATE t SET payload = 'changed' WHERE id = 10", ct); + + long[] originalIds = Enumerable.Range(0, 16).Select(i => (long)i).ToArray(); + // Every result is opened after growth, including the cold reader's first query. + Assert.Equal(originalIds, (await ReadAsync(reader, primaryRange.Bind([0, 1024]))).Rows.Select(row => row[0].AsInteger)); + Assert.Equal(originalIds, (await ReadAsync(reader, "SELECT id FROM t ORDER BY id")).Rows.Select(row => row[0].AsInteger)); + Assert.Equal(originalIds, (await ReadAsync(reader, indexRange.Bind([0, 10240]))).Rows.Select(row => row[0].AsInteger)); + Assert.Equal(originalIds, (await ReadAsync(reader, "SELECT id FROM t WHERE k >= 0 AND k < 10240 ORDER BY k")).Rows.Select(row => row[0].AsInteger)); + Assert.Equal(new long[] { 10, 11, 12, 13, 14, 15 }, (await ReadAsync(reader, primaryRange.Bind([10, 1000]))).Rows.Select(row => row[0].AsInteger)); + Assert.Empty((await ReadAsync(reader, indexRange.Bind([160, 200]))).Rows); + Assert.Equal(Payload, Assert.Single((await ReadAsync(reader, "SELECT payload FROM t WHERE id = 10")).Rows)[0].AsText); + await AssertCountAsync(reader, 16); + + using var latest = db.CreateReaderSession(); + Assert.Equal(1024, (await ReadAsync(latest, primaryRange.Bind([0, 1024]))).Rows.Count); + Assert.Equal(1024, (await ReadAsync(latest, indexRange.Bind([0, 10240]))).Rows.Count); + Assert.Equal("changed", Assert.Single((await ReadAsync(latest, "SELECT payload FROM t WHERE id = 10")).Rows)[0].AsText); + await AssertCountAsync(latest, 1024); + } + finally + { + foreach (string file in new[] { path, path + ".wal", path + ".lock" }) + if (File.Exists(file)) File.Delete(file); + } + } + + [Theory(Timeout = 30_000)] + [InlineData(false)] + [InlineData(true)] + public async Task FirstReadAfterCatalogRootSplitAndTableReplacement_UsesSnapshotSchema(bool preparedFirst) + { + CancellationToken ct = TestContext.Current.CancellationToken; + await using var db = await Database.OpenInMemoryAsync(ct); + await CreateTableAsync(db); + await InsertAsync(db, 0, 16); + Pager pager = GetField(db, "_pager"); + uint catalogRoot = pager.SchemaRootPage; + using var reader = db.CreateReaderSession(); + var range = Prepare("SELECT * FROM t WHERE id >= @lower AND id < @upper ORDER BY id"); + + await db.BeginTransactionAsync(ct); + for (int i = 0; i < 128 && pager.SchemaRootPage == catalogRoot; i++) + await db.ExecuteAsync($"CREATE TABLE catalog_growth_{i} (id INTEGER PRIMARY KEY, description TEXT, amount BIGINT)", ct); + await db.CommitAsync(ct); + Assert.NotEqual(catalogRoot, pager.SchemaRootPage); + await db.ExecuteAsync("DROP TABLE t", ct); + await db.ExecuteAsync("CREATE TABLE t (replacement TEXT, id INTEGER PRIMARY KEY)", ct); + await db.ExecuteAsync("CREATE INDEX ix_t_k ON t(replacement)", ct); + await db.ExecuteAsync("INSERT INTO t VALUES ('new schema', 7)", ct); + + SnapshotRows first = preparedFirst + ? await ReadAsync(reader, range.Bind([0, 16])) + : await ReadAsync(reader, "SELECT * FROM t ORDER BY id"); + AssertOriginalRows(first, 16); + AssertOriginalRows(await ReadAsync(reader, range.Bind([0, 16])), 16); + Assert.Equal(16, (await ReadAsync(reader, "SELECT id FROM t WHERE k >= 0 ORDER BY k")).Rows.Count); + Assert.Equal(Payload, Assert.Single((await ReadAsync(reader, "SELECT payload FROM t WHERE id = 7")).Rows)[0].AsText); + await AssertCountAsync(reader, 16); + + using var latest = db.CreateReaderSession(); + SnapshotRows current = await ReadAsync(latest, "SELECT * FROM t"); + Assert.Equal(new[] { "replacement", "id" }, current.Columns); + Assert.Equal("new schema", Assert.Single(current.Rows)[0].AsText); + await AssertCountAsync(latest, 1); + } + + [Theory(Timeout = 30_000)] + [InlineData(false)] + [InlineData(true)] + public async Task AddedColumnAndNewRows_DoNotChangeSnapshotSchemaOrCount(bool warmReader) + { + CancellationToken ct = TestContext.Current.CancellationToken; + await using var db = await Database.OpenInMemoryAsync(ct); + await CreateTableAsync(db); + await InsertAsync(db, 0, 3); + using var reader = db.CreateReaderSession(); + var range = Prepare("SELECT * FROM t WHERE k >= @lower AND k < @upper ORDER BY k"); + if (warmReader) + AssertOriginalRows(await ReadAsync(reader, range.Bind([0, 100])), 3); + + await db.ExecuteAsync("ALTER TABLE t ADD COLUMN added TEXT", ct); + await db.ExecuteAsync("UPDATE t SET payload = 'changed', added = 'new' WHERE id = 1", ct); + await db.ExecuteAsync("INSERT INTO t VALUES (3, 30, 'new row', 'added')", ct); + + // COUNT's metadata shortcut must use the same snapshot as subsequent planned reads. + await AssertCountAsync(reader, 3); + AssertOriginalRows(await ReadAsync(reader, range.Bind([0, 100])), 3); + AssertOriginalRows(await ReadAsync(reader, "SELECT * FROM t ORDER BY id"), 3); + SnapshotRows point = await ReadAsync(reader, "SELECT * FROM t WHERE id = 1"); + Assert.Equal(new[] { "id", "k", "payload" }, point.Columns); + Assert.Equal(Payload, Assert.Single(point.Rows)[2].AsText); + + using var latest = db.CreateReaderSession(); + SnapshotRows current = await ReadAsync(latest, range.Bind([0, 100])); + Assert.Equal(new[] { "id", "k", "payload", "added" }, current.Columns); + Assert.Equal(new long[] { 0, 1, 2, 3 }, current.Rows.Select(row => row[0].AsInteger)); + Assert.All(current.Rows, row => Assert.Equal(4, row.Length)); + Assert.True(current.Rows[0][3].IsNull); + Assert.Equal("new", current.Rows[1][3].AsText); + Assert.True(current.Rows[2][3].IsNull); + Assert.Equal("added", current.Rows[3][3].AsText); + await AssertCountAsync(latest, 4); + } + + [Theory(Timeout = 30_000)] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task UncommittedAndRolledBackSchemaChanges_DoNotAffectExistingReader(bool warmReader, bool replaceTable) + { + CancellationToken ct = TestContext.Current.CancellationToken; + await using var db = await Database.OpenInMemoryAsync(ct); + await CreateTableAsync(db); + await InsertAsync(db, 0, 3); + using var reader = db.CreateReaderSession(); + var range = Prepare("SELECT * FROM t WHERE id >= @lower AND id < @upper ORDER BY id"); + if (warmReader) + AssertOriginalRows(await ReadAsync(reader, range.Bind([0, 100])), 3); + + await db.BeginTransactionAsync(ct); + try + { + if (replaceTable) + { + await db.ExecuteAsync("DROP TABLE t", ct); + await db.ExecuteAsync("CREATE TABLE t (replacement TEXT, id INTEGER PRIMARY KEY)", ct); + await db.ExecuteAsync("INSERT INTO t VALUES ('uncommitted', 1)", ct); + } + else + { + await db.ExecuteAsync("ALTER TABLE t ADD COLUMN uncommitted TEXT", ct); + await db.ExecuteAsync("DELETE FROM t WHERE id = 0", ct); + await db.ExecuteAsync("UPDATE t SET payload = 'uncommitted' WHERE id = 1", ct); + } + + AssertOriginalRows(await ReadAsync(reader, range.Bind([0, 100])), 3); + AssertOriginalRows(await ReadAsync(reader, "SELECT * FROM t ORDER BY id"), 3); + Assert.Equal(3, (await ReadAsync(reader, "SELECT id FROM t WHERE k >= 0 ORDER BY k")).Rows.Count); + await AssertCountAsync(reader, 3); + } + finally + { + await db.RollbackAsync(ct); + } + + AssertOriginalRows(await ReadAsync(reader, range.Bind([0, 100])), 3); + await AssertCountAsync(reader, 3); + await InsertAsync(db, 3, 1); + AssertOriginalRows(await ReadAsync(reader, range.Bind([0, 100])), 3); + await AssertCountAsync(reader, 3); + using var latest = db.CreateReaderSession(); + AssertOriginalRows(await ReadAsync(latest, range.Bind([0, 100])), 4); + await AssertCountAsync(latest, 4); + } + + [Theory(Timeout = 30_000)] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task ReaderCreatedDuringLegacyRootGrowth_SeesOnlyCommittedCatalog(bool warmReader, bool commit) + { + CancellationToken ct = TestContext.Current.CancellationToken; + await using var db = await Database.OpenInMemoryAsync(ct); + await CreateTableAsync(db); + await InsertAsync(db, 0, 16); + var range = Prepare("SELECT * FROM t WHERE k >= @lower AND k < @upper ORDER BY k"); + await db.BeginTransactionAsync(ct); + await AddRowsAsync(db, 16, 1008); + await db.ExecuteAsync("ALTER TABLE t ADD COLUMN uncommitted TEXT", ct); + using var reader = db.CreateReaderSession(); + if (warmReader) + { + await AssertCountAsync(reader, 16); + AssertOriginalRows(await ReadAsync(reader, range.Bind([0, 10240])), 16); + } + + if (commit) + await db.CommitAsync(ct); + else + await db.RollbackAsync(ct); + + await AssertCountAsync(reader, 16); + AssertOriginalRows(await ReadAsync(reader, range.Bind([0, 10240])), 16); + AssertOriginalRows(await ReadAsync(reader, "SELECT * FROM t ORDER BY id"), 16); + SnapshotRows point = await ReadAsync(reader, "SELECT * FROM t WHERE id = 10"); + Assert.Equal(new[] { "id", "k", "payload" }, point.Columns); + Assert.Equal(Payload, Assert.Single(point.Rows)[2].AsText); + + using var latest = db.CreateReaderSession(); + await AssertCountAsync(latest, commit ? 1024 : 16); + SnapshotRows current = await ReadAsync(latest, range.Bind([0, 10240])); + if (commit) + { + Assert.Equal(new[] { "id", "k", "payload", "uncommitted" }, current.Columns); + Assert.Equal(1024, current.Rows.Count); + Assert.All(current.Rows, row => Assert.True(row[3].IsNull)); + } + else + { + AssertOriginalRows(current, 16); + } + } + + [Fact(Timeout = 30_000)] + public async Task RejectedExplicitConflict_PreservesCatalogCaptureAfterNextSuccessfulWrite() + { + CancellationToken ct = TestContext.Current.CancellationToken; + await using var db = await Database.OpenInMemoryAsync(ct); + await CreateTableAsync(db); + await InsertAsync(db, 0, 16); + await using var rejected = await db.BeginWriteTransactionAsync(ct); + await using (var read = await rejected.ExecuteAsync("SELECT payload FROM t WHERE id = 0", ct)) + Assert.Equal(Payload, Assert.Single(await read.ToListAsync(ct))[0].AsText); + await rejected.ExecuteAsync("UPDATE t SET payload = 'rejected' WHERE id = 1", ct); + + await using (var winner = await db.BeginWriteTransactionAsync(ct)) + { + await winner.ExecuteAsync("UPDATE t SET payload = 'winner' WHERE id = 0", ct); + await winner.CommitAsync(ct); + } + await Assert.ThrowsAsync(() => rejected.CommitAsync(ct).AsTask()); + await db.ExecuteAsync("INSERT INTO t VALUES (16, 160, 'after conflict')", ct); + + using var reader = db.CreateReaderSession(); + // Check before any read can load the fallback: native rejection must not + // permanently force subsequent committed states onto the slow load path. + Assert.NotNull(GetField(reader, "_catalogState").Catalog); + await AssertCountAsync(reader, 17); + SnapshotRows current = await ReadAsync(reader, "SELECT * FROM t ORDER BY id"); + Assert.Equal(Enumerable.Range(0, 17).Select(i => (long)i), current.Rows.Select(row => row[0].AsInteger)); + Assert.Equal("winner", current.Rows[0][2].AsText); + Assert.Equal(Payload, current.Rows[1][2].AsText); + Assert.Equal("after conflict", current.Rows[16][2].AsText); + } + + [Fact(Timeout = 30_000)] + public async Task ColdPhysicalReaderCreatedDuringLegacyWrite_UsesCommittedSnapshotAfterCommit() + { + CancellationToken ct = TestContext.Current.CancellationToken; + await using var db = await Database.OpenInMemoryAsync(ct); + await CreateTableAsync(db); + await InsertAsync(db, 0, 16); + await db.BeginTransactionAsync(ct); + await AddRowsAsync(db, 16, 1008); + await db.ExecuteAsync("ALTER TABLE t ADD COLUMN pending TEXT", ct); + using var reader = db.CreateReaderSession(); + Assert.Null(GetField(reader, "_catalogState").Catalog); + await db.CommitAsync(ct); + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromSeconds(10)); + SnapshotRows original = await Task.Run(async () => + { + await using var physical = reader.OpenTableReader("t", afterRowIdExclusive: null, maxEncodedRowBytes: 1024 * 1024); + var rows = new List(); + while (await physical.MoveNextAsync(timeout.Token)) + { + DbValue[] row = physical.Current.ToArray(); + Assert.Equal(row[0].AsInteger, physical.CurrentRowId); + rows.Add(row); + } + return new SnapshotRows(physical.Columns.Select(column => column.Name).ToArray(), rows); + }, timeout.Token).WaitAsync(TimeSpan.FromSeconds(10), ct); + AssertOriginalRows(original, 16); + await AssertCountAsync(reader, 16); + } + + [Theory(Timeout = 30_000)] + [InlineData(false)] + [InlineData(true)] + public async Task ReadersBetweenOverlappingCommitPublications_KeepCoherentCatalogs(bool completeSecondFirst) + { + CancellationToken ct = TestContext.Current.CancellationToken; + var interceptor = new BlockingCommitEndInterceptor(); + var options = new DatabaseOptions + { + StorageEngineOptions = new StorageEngineOptions + { + PagerOptions = new PagerOptions { Interceptors = [interceptor] }, + }, + }; + await using var db = await Database.OpenInMemoryAsync(options, ct); + await CreateTableAsync(db); + await InsertAsync(db, 0, 16); + await db.ExecuteAsync("CREATE TABLE u (id INTEGER PRIMARY KEY, payload TEXT)", ct); + await db.ExecuteAsync("INSERT INTO u VALUES (1, 'original')", ct); + using var original = db.CreateReaderSession(); + await using var firstWriter = await db.BeginWriteTransactionAsync(ct); + await using var secondWriter = await db.BeginWriteTransactionAsync(ct); + for (int i = 16; i < 1024; i++) + await firstWriter.ExecuteAsync($"INSERT INTO t VALUES ({i}, {i * 10L}, '{Payload}')", ct); + await secondWriter.ExecuteAsync("INSERT INTO u VALUES (2, 'second')", ct); + var range = Prepare("SELECT * FROM t WHERE id >= @lower AND id < @upper ORDER BY id"); + + Task? firstCommit = null; + Task? secondCommit = null; + try + { + interceptor.Arm(); + firstCommit = firstWriter.CommitAsync(ct).AsTask(); + await interceptor.WaitForCommitAsync(0, ct); + // WAL publication has finished, but the writer's shared-catalog refresh is blocked. + using var between = db.CreateReaderSession(); + AssertOriginalRows(await ReadAsync(between, range.Bind([0, 1024])), 1024); + await AssertCountAsync(between, 1024); + Assert.Equal(new[] { 1L }, (await ReadAsync(between, "SELECT id FROM u ORDER BY id")).Rows.Select(row => row[0].AsInteger)); + + secondCommit = secondWriter.CommitAsync(ct).AsTask(); + await interceptor.WaitForCommitAsync(1, ct); + using var afterBothPublications = db.CreateReaderSession(); + AssertOriginalRows(await ReadAsync(afterBothPublications, range.Bind([0, 1024])), 1024); + Assert.Equal(2L, Assert.Single((await ReadAsync(afterBothPublications, "SELECT COUNT(*) FROM u")).Rows)[0].AsInteger); + + int firstToComplete = completeSecondFirst ? 1 : 0; + interceptor.Release(firstToComplete); + await (completeSecondFirst ? secondCommit : firstCommit).WaitAsync(TimeSpan.FromSeconds(10), ct); + interceptor.Release(1 - firstToComplete); + await Task.WhenAll(firstCommit, secondCommit).WaitAsync(TimeSpan.FromSeconds(10), ct); + + AssertOriginalRows(await ReadAsync(original, range.Bind([0, 1024])), 16); + await AssertCountAsync(original, 16); + AssertOriginalRows(await ReadAsync(between, range.Bind([0, 1024])), 1024); + Assert.Equal(new[] { 1L }, (await ReadAsync(between, "SELECT id FROM u ORDER BY id")).Rows.Select(row => row[0].AsInteger)); + Assert.Equal(new long[] { 1, 2 }, (await ReadAsync(afterBothPublications, "SELECT id FROM u ORDER BY id")).Rows.Select(row => row[0].AsInteger)); + using var latest = db.CreateReaderSession(); + await AssertCountAsync(latest, 1024); + Assert.Equal(2L, Assert.Single((await ReadAsync(latest, "SELECT COUNT(*) FROM u")).Rows)[0].AsInteger); + } + finally + { + interceptor.Release(0); + interceptor.Release(1); + if (firstCommit is not null) + await firstCommit.WaitAsync(TimeSpan.FromSeconds(10), ct); + if (secondCommit is not null) + await secondCommit.WaitAsync(TimeSpan.FromSeconds(10), ct); + } + } + + [Fact(Timeout = 30_000)] + public async Task ColdReaderCapturedAfterCheckpoint_KeepsRowsAndSchemaAfterGrowthAndAnotherCheckpoint() + { + CancellationToken ct = TestContext.Current.CancellationToken; + string path = Path.Combine(Path.GetTempPath(), $"csharpdb_snapshot_checkpoint_{Guid.NewGuid():N}.db"); + var options = new DatabaseOptions + { + StorageEngineOptions = new StorageEngineOptions + { + PagerOptions = new PagerOptions { CheckpointPolicy = new FrameCountCheckpointPolicy(10_000) }, + }, + }; + try + { + await using var db = await Database.OpenAsync(path, options, ct); + await CreateTableAsync(db); + await InsertAsync(db, 0, 16); + await db.CheckpointAsync(ct); + using var reader = db.CreateReaderSession(); + // No query opens a snapshot pager before the writer replaces the on-disk pages. + await InsertAsync(db, 16, 1008); + await db.ExecuteAsync("ALTER TABLE t ADD COLUMN added TEXT", ct); + await db.ExecuteAsync("UPDATE t SET payload = 'changed' WHERE id = 10", ct); + await db.CheckpointAsync(ct); + + var range = Prepare("SELECT * FROM t WHERE id >= @lower AND id < @upper ORDER BY id"); + AssertOriginalRows(await ReadAsync(reader, range.Bind([0, 1024])), 16); + AssertOriginalRows(await ReadAsync(reader, "SELECT * FROM t WHERE k >= 0 ORDER BY k"), 16); + await AssertCountAsync(reader, 16); + using var latest = db.CreateReaderSession(); + SnapshotRows current = await ReadAsync(latest, range.Bind([0, 1024])); + Assert.Equal(new[] { "id", "k", "payload", "added" }, current.Columns); + Assert.Equal(1024, current.Rows.Count); + Assert.Equal("changed", current.Rows[10][2].AsText); + } + finally + { + foreach (string file in new[] { path, path + ".wal", path + ".lock" }) + if (File.Exists(file)) File.Delete(file); + } + } + + [Fact(Timeout = 30_000)] + public async Task ViewsJoinsAndSystemCatalog_KeepSnapshotDefinitionsAfterTableReplacement() + { + CancellationToken ct = TestContext.Current.CancellationToken; + await using var db = await Database.OpenInMemoryAsync(ct); + await CreateTableAsync(db); + await InsertAsync(db, 0, 3); + await db.ExecuteAsync("CREATE TABLE u (id INTEGER PRIMARY KEY, label TEXT)", ct); + await db.ExecuteAsync("INSERT INTO u VALUES (1, 'old label')", ct); + await db.ExecuteAsync("CREATE VIEW v AS SELECT id, payload FROM t", ct); + using var reader = db.CreateReaderSession(); + + await db.ExecuteAsync("DROP VIEW v", ct); + await db.ExecuteAsync("DROP TABLE t", ct); + await db.ExecuteAsync("CREATE TABLE t (id INTEGER PRIMARY KEY, replacement TEXT)", ct); + await db.ExecuteAsync("CREATE INDEX ix_t_k ON t(replacement)", ct); + await db.ExecuteAsync("INSERT INTO t VALUES (1, 'new row')", ct); + await db.ExecuteAsync("ALTER TABLE u ADD COLUMN added TEXT", ct); + await db.ExecuteAsync("UPDATE u SET label = 'new label' WHERE id = 1", ct); + await db.ExecuteAsync("CREATE VIEW v AS SELECT id, replacement FROM t", ct); + + SnapshotRows view = await ReadAsync(reader, "SELECT * FROM v ORDER BY id"); + Assert.Equal(new[] { "id", "payload" }, view.Columns); + Assert.Equal(new long[] { 0, 1, 2 }, view.Rows.Select(row => row[0].AsInteger)); + Assert.All(view.Rows, row => Assert.Equal(Payload, row[1].AsText)); + SnapshotRows join = await ReadAsync(reader, + "SELECT t.id AS item_id, t.payload AS original_payload, u.label AS label FROM t JOIN u ON t.id = u.id ORDER BY t.id"); + Assert.Equal(new[] { "item_id", "original_payload", "label" }, join.Columns); + DbValue[] joined = Assert.Single(join.Rows); + Assert.Equal(1L, joined[0].AsInteger); + Assert.Equal(Payload, joined[1].AsText); + Assert.Equal("old label", joined[2].AsText); + const string columnsSql = "SELECT column_name FROM sys.columns WHERE table_name = 't' ORDER BY ordinal_position"; + const string indexSql = "SELECT column_name FROM sys.indexes WHERE index_name = 'ix_t_k'"; + Assert.Equal(new[] { "id", "k", "payload" }, (await ReadAsync(reader, columnsSql)).Rows.Select(row => row[0].AsText)); + Assert.Equal("k", Assert.Single((await ReadAsync(reader, indexSql)).Rows)[0].AsText); + Assert.Equal(3L, Assert.Single((await ReadAsync(reader, "SELECT column_count FROM sys.tables WHERE table_name = 't'")).Rows)[0].AsInteger); + Assert.Equal(new[] { "id", "label" }, (await ReadAsync(reader, + "SELECT column_name FROM sys.columns WHERE table_name = 'u' ORDER BY ordinal_position")).Rows.Select(row => row[0].AsText)); + Assert.Contains("payload", Assert.Single((await ReadAsync(reader, + "SELECT sql FROM sys.views WHERE view_name = 'v'")).Rows)[0].AsText, StringComparison.OrdinalIgnoreCase); + + using var latest = db.CreateReaderSession(); + SnapshotRows latestView = await ReadAsync(latest, "SELECT * FROM v ORDER BY id"); + Assert.Equal(new[] { "id", "replacement" }, latestView.Columns); + Assert.Equal("new row", Assert.Single(latestView.Rows)[1].AsText); + Assert.Equal(new[] { "id", "replacement" }, (await ReadAsync(latest, columnsSql)).Rows.Select(row => row[0].AsText)); + Assert.Equal("replacement", Assert.Single((await ReadAsync(latest, indexSql)).Rows)[0].AsText); + } + + private static async Task CreateTableAsync(Database db) + { + await db.ExecuteAsync("CREATE TABLE t (id INTEGER PRIMARY KEY, k BIGINT, payload TEXT)", Ct); + await db.ExecuteAsync("CREATE INDEX ix_t_k ON t(k)", Ct); + } + + private static async Task InsertAsync(Database db, int start, int count) + { + await db.BeginTransactionAsync(Ct); + await AddRowsAsync(db, start, count); + await db.CommitAsync(Ct); + } + + private static async Task AddRowsAsync(Database db, int start, int count) + { + var batch = db.PrepareInsertBatch("t", count); + for (int i = start; i < start + count; i++) + batch.AddRow([DbValue.FromInteger(i), DbValue.FromInteger(i * 10L), DbValue.FromText(Payload)]); + await batch.ExecuteAsync(Ct); + } + + private static PreparedSelectTemplate Prepare(string sql) + => Assert.IsType(PreparedSelectTemplate.TryCreate(Parser.Parse(sql), ["lower", "upper"])); + + private static void AssertOriginalRows(SnapshotRows result, int count) + { + Assert.Equal(new[] { "id", "k", "payload" }, result.Columns); + Assert.Equal(Enumerable.Range(0, count).Select(i => (long)i), result.Rows.Select(row => row[0].AsInteger)); + Assert.All(result.Rows, row => + { + Assert.Equal(3, row.Length); + Assert.Equal(row[0].AsInteger * 10L, row[1].AsInteger); + Assert.Equal(Payload, row[2].AsText); + }); + } + + private static async Task AssertCountAsync(Database.ReaderSession reader, long expected) + { + SnapshotRows count = await ReadAsync(reader, "SELECT COUNT(*) FROM t"); + Assert.Single(count.Columns); + Assert.Equal(expected, Assert.Single(Assert.Single(count.Rows)).AsInteger); + } + + private static Task ReadAsync(Database.ReaderSession reader, string sql) + => ReadAsync(ct => reader.ExecuteReadAsync(sql, ct)); + + private static Task ReadAsync(Database.ReaderSession reader, Statement statement) + => ReadAsync(ct => reader.ExecuteReadAsync(statement, ct)); + + private static async Task ReadAsync(Func> execute) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(Ct); + timeout.CancelAfter(TimeSpan.FromSeconds(10)); + // Dispatch first: a bad root can loop synchronously before ExecuteReadAsync returns a task. + return await Task.Run(async () => + { + await using var result = await execute(timeout.Token); + return new SnapshotRows(result.Schema.Select(column => column.Name).ToArray(), await result.ToListAsync(timeout.Token)); + }, timeout.Token).WaitAsync(TimeSpan.FromSeconds(10), Ct); + } + + private sealed record SnapshotRows(string[] Columns, List Rows); + + private sealed class BlockingCommitEndInterceptor : IPageOperationInterceptor + { + private readonly TaskCompletionSource[] _started = + [new(TaskCreationOptions.RunContinuationsAsynchronously), new(TaskCreationOptions.RunContinuationsAsynchronously)]; + private readonly TaskCompletionSource[] _released = + [new(TaskCreationOptions.RunContinuationsAsynchronously), new(TaskCreationOptions.RunContinuationsAsynchronously)]; + private int _armed; + private int _commitCount; + + internal void Arm() => Volatile.Write(ref _armed, 1); + internal void Release(int ordinal) => _released[ordinal].TrySetResult(); + internal Task WaitForCommitAsync(int ordinal, CancellationToken ct) + => _started[ordinal].Task.WaitAsync(TimeSpan.FromSeconds(10), ct); + + public ValueTask OnCommitEndAsync(int dirtyPageCount, bool succeeded, CancellationToken ct = default) + { + if (!succeeded || Volatile.Read(ref _armed) == 0) return ValueTask.CompletedTask; + int ordinal = Interlocked.Increment(ref _commitCount) - 1; + if (ordinal >= _started.Length) return ValueTask.CompletedTask; + _started[ordinal].TrySetResult(); + return new ValueTask(_released[ordinal].Task.WaitAsync(ct)); + } + + public ValueTask OnBeforeReadAsync(uint pageId, CancellationToken ct = default) => ValueTask.CompletedTask; + public ValueTask OnAfterReadAsync(uint pageId, PageReadSource source, CancellationToken ct = default) => ValueTask.CompletedTask; + public ValueTask OnBeforeWriteAsync(uint pageId, CancellationToken ct = default) => ValueTask.CompletedTask; + public ValueTask OnAfterWriteAsync(uint pageId, bool succeeded, CancellationToken ct = default) => ValueTask.CompletedTask; + public ValueTask OnCommitStartAsync(int dirtyPageCount, CancellationToken ct = default) => ValueTask.CompletedTask; + public ValueTask OnCheckpointStartAsync(int committedFrameCount, CancellationToken ct = default) => ValueTask.CompletedTask; + public ValueTask OnCheckpointEndAsync(int committedFrameCount, bool succeeded, CancellationToken ct = default) => ValueTask.CompletedTask; + public ValueTask OnRecoveryStartAsync(CancellationToken ct = default) => ValueTask.CompletedTask; + public ValueTask OnRecoveryEndAsync(bool succeeded, CancellationToken ct = default) => ValueTask.CompletedTask; + } + + private static T GetField(object target, string name) + => (T)target.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(target)!; +} diff --git a/tests/CSharpDB.Tests/SnapshotPagerWriteStateTests.cs b/tests/CSharpDB.Tests/SnapshotPagerWriteStateTests.cs new file mode 100644 index 00000000..47a433c9 --- /dev/null +++ b/tests/CSharpDB.Tests/SnapshotPagerWriteStateTests.cs @@ -0,0 +1,173 @@ +using CSharpDB.Storage.BTrees; +using CSharpDB.Storage.Checkpointing; +using CSharpDB.Storage.Device; +using CSharpDB.Storage.Wal; + +namespace CSharpDB.Tests; + +public sealed class SnapshotPagerWriteStateTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task SnapshotDiagnosticsAndReset_DoNotChangeNamedWriterCounters() + { + await using var writer = await CreateWriterAsync(); + await writer.BeginTransactionAsync(Ct); + var tree = new BTree(writer, await BTree.CreateNewAsync(writer, Ct), logicalTableName: "bench"); + for (int key = 1; key <= 128; key++) + { + byte[] payload = new byte[512]; + payload[0] = (byte)key; + await tree.InsertAsync(key, payload, Ct); + } + await writer.CommitAsync(Ct); + + CommitPathDiagnosticsSnapshot before = writer.GetCommitPathDiagnosticsSnapshot(); + CommitPathBTreeResourceDiagnosticsSnapshot resource = Assert.Single(before.BTreeResourceDiagnostics!); + Assert.Equal("table:bench:rowid", resource.ResourceName); + Assert.True(resource.LeafSplitCount > 0); + Assert.True(resource.RootSplitCount > 0); + + WalSnapshot snapshot = writer.AcquireReaderSnapshot(); + try + { + using var first = writer.CreateSnapshotReader(snapshot); + using var second = writer.CreateSnapshotReader(snapshot); + foreach (Pager reader in new[] { first, second }) + { + AssertNoStructuralDiagnostics(reader); + var readerTree = new BTree(reader, tree.RootPageId, logicalTableName: "bench"); + byte[] row = Assert.IsType(await readerTree.FindAsync(64, Ct)); + Assert.Equal((byte)64, row[0]); + reader.ResetCommitPathDiagnostics(); + AssertNoStructuralDiagnostics(reader); + } + + CommitPathDiagnosticsSnapshot after = writer.GetCommitPathDiagnosticsSnapshot(); + Assert.Equal(StructuralCounters(before), StructuralCounters(after)); + Assert.Equal(resource, Assert.Single(after.BTreeResourceDiagnostics!)); + + writer.ResetCommitPathDiagnostics(); + AssertNoStructuralDiagnostics(writer); + AssertNoStructuralDiagnostics(first); + AssertNoStructuralDiagnostics(second); + + await writer.BeginTransactionAsync(Ct); + for (int key = 129; key <= 256; key++) + await tree.InsertAsync(key, new byte[512], Ct); + await writer.CommitAsync(Ct); + CommitPathBTreeResourceDiagnosticsSnapshot resumed = + Assert.Single(writer.GetCommitPathDiagnosticsSnapshot().BTreeResourceDiagnostics!); + Assert.Equal(resource.ResourceName, resumed.ResourceName); + Assert.True(resumed.LeafSplitCount > 0); + AssertNoStructuralDiagnostics(first); + AssertNoStructuralDiagnostics(second); + } + finally + { + writer.ReleaseReaderSnapshot(snapshot); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SnapshotAllocationAndFree_ReturnFaultedOperationsWithoutAffectingWriter(bool canceled) + { + await using var writer = await CreateWriterAsync(); + await writer.BeginTransactionAsync(Ct); + uint pageId = await writer.AllocatePageAsync(Ct); + byte[] page = await writer.GetPageAsync(pageId, Ct); + page[128] = 10; + await writer.MarkDirtyAsync(pageId, Ct); + await writer.CommitAsync(Ct); + + WalSnapshot snapshot = writer.AcquireReaderSnapshot(); + try + { + using var first = writer.CreateSnapshotReader(snapshot); + using var second = writer.CreateSnapshotReader(snapshot); + uint snapshotPageCount = first.PageCount; + uint snapshotFreelistHead = first.FreelistHead; + CancellationToken rejectionToken = canceled ? new CancellationToken(true) : Ct; + foreach (Pager reader in new[] { first, second }) + { + // Capture each operation outside the assertion delegate: the API + // must return a faulted ValueTask rather than throw synchronously. + ValueTask allocation = reader.AllocatePageAsync(rejectionToken); + Assert.True(allocation.IsFaulted); + var allocationError = await Assert.ThrowsAsync(() => allocation.AsTask()); + Assert.Equal("Cannot allocate pages on a read-only snapshot pager.", allocationError.Message); + + foreach (uint target in new[] { PageConstants.NullPageId, pageId }) + { + ValueTask free = reader.FreePageAsync(target, rejectionToken); + Assert.True(free.IsFaulted); + var freeError = await Assert.ThrowsAsync(() => free.AsTask()); + Assert.Equal("Cannot free pages on a read-only snapshot pager.", freeError.Message); + } + Assert.Equal(snapshotPageCount, reader.PageCount); + Assert.Equal(snapshotFreelistHead, reader.FreelistHead); + } + + await writer.BeginTransactionAsync(Ct); + byte[] changed = await writer.GetPageAsync(pageId, Ct); + changed[128] = 20; + await writer.MarkDirtyAsync(pageId, Ct); + uint freedPage = await writer.AllocatePageAsync(Ct); + await writer.FreePageAsync(freedPage, Ct); + await writer.CommitAsync(Ct); + + await writer.BeginTransactionAsync(Ct); + Assert.Equal(freedPage, await writer.AllocatePageAsync(Ct)); + await writer.CommitAsync(Ct); + + // These reads occur after the commits, so the old value must come + // from the retained snapshot rather than a previously cached page. + Assert.Equal((byte)10, (await first.GetPageAsync(pageId, Ct))[128]); + Assert.Equal((byte)10, (await second.GetPageAsync(pageId, Ct))[128]); + Assert.Equal(snapshotPageCount, first.PageCount); + Assert.Equal(snapshotPageCount, second.PageCount); + + WalSnapshot latest = writer.AcquireReaderSnapshot(); + try + { + using var latestReader = writer.CreateSnapshotReader(latest); + Assert.Equal((byte)20, (await latestReader.GetPageAsync(pageId, Ct))[128]); + Assert.Equal(writer.PageCount, latestReader.PageCount); + } + finally + { + writer.ReleaseReaderSnapshot(latest); + } + } + finally + { + writer.ReleaseReaderSnapshot(snapshot); + } + } + + private static void AssertNoStructuralDiagnostics(Pager pager) + { + CommitPathDiagnosticsSnapshot diagnostics = pager.GetCommitPathDiagnosticsSnapshot(); + Assert.Equal((0L, 0L, 0L, 0L, 0L, 0L, 0L), StructuralCounters(diagnostics)); + Assert.Empty(diagnostics.BTreeResourceDiagnostics ?? []); + } + + private static (long, long, long, long, long, long, long) StructuralCounters(CommitPathDiagnosticsSnapshot value) + => (value.BTreeLeafSplitCount, value.BTreeRightEdgeLeafSplitCount, + value.BTreeInteriorInsertCount, value.BTreeRightEdgeInteriorInsertCount, + value.BTreeInteriorSplitCount, value.BTreeRightEdgeInteriorSplitCount, + value.BTreeRootSplitCount); + + private static async ValueTask CreateWriterAsync() + { + var index = new WalIndex(); + var pager = await Pager.CreateAsync(new MemoryStorageDevice(), new MemoryWriteAheadLog(index), index, + new PagerOptions { CheckpointPolicy = new FrameCountCheckpointPolicy(int.MaxValue) }, Ct); + await pager.InitializeNewDatabaseAsync(Ct); + await pager.RecoverAsync(Ct); + return pager; + } +} diff --git a/tests/CSharpDB.Tests/SqlTypeCoercionTests.cs b/tests/CSharpDB.Tests/SqlTypeCoercionTests.cs index 1df5d314..835dd1ef 100644 --- a/tests/CSharpDB.Tests/SqlTypeCoercionTests.cs +++ b/tests/CSharpDB.Tests/SqlTypeCoercionTests.cs @@ -1,8 +1,10 @@ +using System.Runtime.CompilerServices; using CSharpDB.Execution; using CSharpDB.Primitives; namespace CSharpDB.Tests; +[Collection(SqlTypeCoercionCollection.Name)] public sealed class SqlTypeCoercionTests { private static readonly ColumnDefinition IntegerColumn = new() @@ -41,21 +43,60 @@ public void CoerceForAssignment_SuccessPathDoesNotAllocateQualifiedTarget() input, IntegerColumn, tableName: "bench"); + _ = FormatQualifiedTarget("bench", IntegerColumn.Name); - long before = GC.GetAllocatedBytesForCurrentThread(); - for (int i = 0; i < 4096; i++) + long allocatedBytes; + long positiveControlBytes; + + // Background GC can inflate this counter with unused allocation-buffer space: + // https://github.com/dotnet/runtime/issues/134724 + // The budget also accommodates the deliberately allocating control below. + Assert.True( + GC.TryStartNoGCRegion(1024 * 1024, disallowFullBlockingGC: false), + "Could not enter a no-GC region for the allocation measurement."); + try { - result = SqlTypeCoercion.CoerceForAssignment( - input, - IntegerColumn, - tableName: "bench"); - } + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < 4096; i++) + { + result = SqlTypeCoercion.CoerceForAssignment( + input, + IntegerColumn, + tableName: "bench"); + } - long allocatedBytes = GC.GetAllocatedBytesForCurrentThread() - before; + allocatedBytes = GC.GetAllocatedBytesForCurrentThread() - before; + + // Model the eager qualified-target formatting this test guards against. + // The no-inline call keeps each returned string observable. + before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < 4096; i++) + GC.KeepAlive(FormatQualifiedTarget("bench", IntegerColumn.Name)); + + positiveControlBytes = GC.GetAllocatedBytesForCurrentThread() - before; + } + finally + { + // A premature collection or exhausted budget invalidates the sample. + GC.EndNoGCRegion(); + } Assert.True( allocatedBytes <= 1024, $"Successful assignment coercion allocated {allocatedBytes:N0} bytes."); Assert.Equal(42, result.AsInteger); + Assert.True( + positiveControlBytes > 1024, + $"The qualified-target control allocated only {positiveControlBytes:N0} bytes."); } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static string FormatQualifiedTarget(string tableName, string columnName) + => $"column '{tableName}.{columnName}'"; +} + +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class SqlTypeCoercionCollection +{ + public const string Name = "SqlTypeCoercion"; } diff --git a/tests/CSharpDB.Tests/TableScanBatchLifecycleTests.cs b/tests/CSharpDB.Tests/TableScanBatchLifecycleTests.cs new file mode 100644 index 00000000..52826573 --- /dev/null +++ b/tests/CSharpDB.Tests/TableScanBatchLifecycleTests.cs @@ -0,0 +1,253 @@ +using System.Reflection; +using CSharpDB.Engine; +using CSharpDB.Execution; +using CSharpDB.Primitives; +using CSharpDB.Storage.Catalog; + +namespace CSharpDB.Tests; + +public sealed class TableScanBatchLifecycleTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task RowOnlyLifecycle_DoesNotAllocateBatchStorage() + { + await using var db = await CreateAsync(); + await using var scan = CreateScan(db); + AssertNoBatchStorage(scan); + scan.SetReuseCurrentBatch(false); + AssertNoBatchStorage(scan); + for (int execution = 0; execution < 2; execution++) + { + await scan.OpenAsync(Ct); + AssertNoBatchStorage(scan); + for (int id = 0; id < 130; id++) + { + Assert.True(await scan.MoveNextAsync(Ct)); + AssertRow(scan.Current, id); + AssertNoBatchStorage(scan); + } + Assert.False(await scan.MoveNextAsync(Ct)); + await scan.DisposeAsync(); + AssertNoBatchStorage(scan); + } + } + + [Fact] + public async Task EmptyTable_RowReadsStayLazyAndBatchLifecyclePreservesEmptySchema() + { + await using var db = await CreateAsync(0); + await using var scan = CreateScan(db); + var batches = (IBatchOperator)scan; + AssertNoBatchStorage(scan); + await scan.OpenAsync(Ct); + Assert.False(await scan.MoveNextAsync(Ct)); + AssertNoBatchStorage(scan); + await scan.DisposeAsync(); + AssertNoBatchStorage(scan); + + RowBatch beforeOpen = batches.CurrentBatch; + Assert.Equal(3, beforeOpen.ColumnCount); + Assert.Equal(0, beforeOpen.Count); + await scan.OpenAsync(Ct); + AssertNoBatchStorage(scan); + Assert.False(await batches.MoveNextBatchAsync(Ct)); + RowBatch afterEmptyRead = batches.CurrentBatch; + Assert.NotSame(beforeOpen, afterEmptyRead); + Assert.Equal(3, afterEmptyRead.ColumnCount); + Assert.Equal(0, afterEmptyRead.Count); + + await scan.DisposeAsync(); + await scan.OpenAsync(Ct); + AssertNoBatchStorage(scan); + RowBatch reopened = batches.CurrentBatch; + Assert.NotSame(afterEmptyRead, reopened); + Assert.Equal(3, reopened.ColumnCount); + Assert.Equal(0, reopened.Count); + Assert.False(await batches.MoveNextBatchAsync(Ct)); + Assert.Equal(0, batches.CurrentBatch.Count); + } + + [Fact] + public async Task BatchReads_AreLazyAndReuseStorageAcrossTheSixtyFourRowBoundary() + { + await using var db = await CreateAsync(); + await using var scan = CreateScan(db); + var batches = (IBatchOperator)scan; + AssertNoBatchStorage(scan); + RowBatch beforeOpen = batches.CurrentBatch; + Assert.Equal(3, beforeOpen.ColumnCount); + Assert.Equal(0, beforeOpen.Count); + + await scan.OpenAsync(Ct); + AssertNoBatchStorage(scan); + RowBatch? first = null; + int nextId = 0; + foreach (int expectedCount in new[] { 64, 64, 2 }) + { + Assert.True(await batches.MoveNextBatchAsync(Ct)); + RowBatch batch = batches.CurrentBatch; + Assert.NotSame(beforeOpen, batch); + if (first is null) first = batch; + else Assert.Same(first, batch); + Assert.Equal(expectedCount, batch.Count); + for (int row = 0; row < batch.Count; row++) + AssertRow(batch.GetRowSpan(row), nextId++); + } + Assert.Equal(130, nextId); + Assert.False(await batches.MoveNextBatchAsync(Ct)); + Assert.Equal(0, batches.CurrentBatch.Count); + } + + [Fact] + public async Task ReuseDisabled_RetainedBatchesSurviveFurtherReadsAndReopen() + { + await using var db = await CreateAsync(); + await using var scan = CreateScan(db); + var batches = (IBatchOperator)scan; + await scan.OpenAsync(Ct); + scan.SetReuseCurrentBatch(false); + AssertNoBatchStorage(scan); + Assert.False(batches.ReusesCurrentBatch); + + var retained = new List(); + while (await batches.MoveNextBatchAsync(Ct)) + { + RowBatch batch = batches.CurrentBatch; + Assert.DoesNotContain(retained, previous => ReferenceEquals(previous, batch)); + retained.Add(batch); + } + Assert.Equal(new[] { 64, 64, 2 }, retained.Select(batch => batch.Count)); + + await scan.DisposeAsync(); + await scan.OpenAsync(Ct); + AssertNoBatchStorage(scan); + scan.SetReuseCurrentBatch(true); + Assert.True(await batches.MoveNextBatchAsync(Ct)); + RowBatch reopened = batches.CurrentBatch; + Assert.DoesNotContain(retained, previous => ReferenceEquals(previous, reopened)); + scan.SetReuseCurrentBatch(false); + AssertNoBatchStorage(scan); + AssertRow(reopened.GetRowSpan(0), 0); + Assert.True(await batches.MoveNextBatchAsync(Ct)); + Assert.NotSame(reopened, batches.CurrentBatch); + + int nextId = 0; + foreach (RowBatch batch in retained) + for (int row = 0; row < batch.Count; row++) + AssertRow(batch.GetRowSpan(row), nextId++); + Assert.Equal(130, nextId); + } + + [Theory] + [InlineData("upper", 2)] + [InlineData("selected", 3)] + [InlineData("empty", 0)] + public async Task ReopenedBatch_UsesCurrentDecodeWidthAndSelection(string mode, int expectedWidth) + { + await using var db = await CreateAsync(); + await using var scan = CreateScan(db); + var batches = (IBatchOperator)scan; + await scan.OpenAsync(Ct); + Assert.True(await batches.MoveNextBatchAsync(Ct)); + RowBatch original = batches.CurrentBatch; + Assert.Equal(3, original.ColumnCount); + await scan.DisposeAsync(); + + if (mode == "upper") scan.SetDecodedColumnUpperBound(1); + else if (mode == "selected") scan.SetDecodedColumnIndices([2, 0, 2, -1, 99]); + else scan.SetDecodedColumnIndices([]); + + await scan.OpenAsync(Ct); + AssertNoBatchStorage(scan); + RowBatch empty = batches.CurrentBatch; + Assert.NotSame(original, empty); + Assert.Equal(expectedWidth, empty.ColumnCount); + Assert.Equal(0, empty.Count); + int nextId = 0; + while (await batches.MoveNextBatchAsync(Ct)) + { + RowBatch batch = batches.CurrentBatch; + Assert.Same(empty, batch); + Assert.Equal(expectedWidth, batch.ColumnCount); + for (int rowIndex = 0; rowIndex < batch.Count; rowIndex++, nextId++) + { + DbValue[] row = batch.GetRowSpan(rowIndex).ToArray(); + Assert.Equal(expectedWidth, row.Length); + if (mode == "upper") + Assert.Equal(new long[] { nextId, nextId * 10 }, row.Select(value => value.AsInteger)); + else if (mode == "selected") + { + Assert.Equal(nextId, row[0].AsInteger); + Assert.True(row[1].IsNull); + Assert.Equal(1000 + nextId, row[2].AsInteger); + } + } + } + Assert.Equal(130, nextId); + + await scan.DisposeAsync(); + scan.SetDecodedColumnUpperBound(2); + await scan.OpenAsync(Ct); + AssertNoBatchStorage(scan); + Assert.True(await batches.MoveNextBatchAsync(Ct)); + Assert.Equal(3, batches.CurrentBatch.ColumnCount); + AssertRow(batches.CurrentBatch.GetRowSpan(0), 0); + } + + [Fact] + public async Task AlternatingRowAndBatchReads_AdvanceOneSharedCursorWithoutSkippingRows() + { + await using var db = await CreateAsync(); + await using var scan = CreateScan(db); + var batches = (IBatchOperator)scan; + await scan.OpenAsync(Ct); + int nextId = 0; + for (int cycle = 0; cycle < 2; cycle++) + { + Assert.True(await scan.MoveNextAsync(Ct)); + AssertRow(scan.Current, nextId++); + Assert.True(await batches.MoveNextBatchAsync(Ct)); + Assert.Equal(64, batches.CurrentBatch.Count); + for (int row = 0; row < batches.CurrentBatch.Count; row++) + AssertRow(batches.CurrentBatch.GetRowSpan(row), nextId++); + } + Assert.Equal(130, nextId); + Assert.False(await scan.MoveNextAsync(Ct)); + Assert.False(await batches.MoveNextBatchAsync(Ct)); + } + + private static async Task CreateAsync(int rowCount = 130) + { + Database db = await Database.OpenInMemoryAsync(Ct); + await db.ExecuteAsync("CREATE TABLE t (id INTEGER PRIMARY KEY, value INTEGER, extra INTEGER)", Ct); + if (rowCount == 0) return db; + await db.BeginTransactionAsync(Ct); + var insert = db.PrepareInsertBatch("t", rowCount); + for (int id = 0; id < rowCount; id++) + insert.AddRow([DbValue.FromInteger(id), DbValue.FromInteger(id * 10), DbValue.FromInteger(1000 + id)]); + await insert.ExecuteAsync(Ct); + await db.CommitAsync(Ct); + return db; + } + + private static TableScanOperator CreateScan(Database db) + { + var catalog = Assert.IsType(typeof(Database) + .GetField("_catalog", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(db)); + return new TableScanOperator(catalog.GetTableTree("t"), catalog.GetTable("t")!); + } + + private static void AssertNoBatchStorage(TableScanOperator scan) + => Assert.Null(typeof(TableScanOperator) + .GetField("_currentBatch", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(scan)); + + private static void AssertRow(ReadOnlySpan row, int id) + { + Assert.Equal(3, row.Length); + Assert.Equal(id, row[0].AsInteger); + Assert.Equal(id * 10, row[1].AsInteger); + Assert.Equal(1000 + id, row[2].AsInteger); + } +} diff --git a/tests/CSharpDB.Tests/WalCheckpointBatchTests.cs b/tests/CSharpDB.Tests/WalCheckpointBatchTests.cs index 6848f95f..454cdc14 100644 --- a/tests/CSharpDB.Tests/WalCheckpointBatchTests.cs +++ b/tests/CSharpDB.Tests/WalCheckpointBatchTests.cs @@ -6,6 +6,286 @@ namespace CSharpDB.Tests; public class WalCheckpointBatchTests { + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Checkpoint_InvalidCopiedPrefix_PreservesLaterCommitAfterReopen(bool corruptSupersededFrame) + { + var ct = TestContext.Current.CancellationToken; + string dbPath = Path.Combine(Path.GetTempPath(), $"csharpdb_checkpoint_prefix_reopen_{Guid.NewGuid():N}.db"); + byte[] page0 = CreatePage(0, 1); + byte[] page1 = CreatePage(1, 1); + byte[] replacedPage1 = CreatePage(1, 2); + byte[] retainedPage2 = CreatePage(2, 3); + try + { + await using (var device = new FileStorageDevice(dbPath, createNew: true)) + await using (var wal = new WriteAheadLog(dbPath, new WalIndex())) + { + await wal.OpenAsync(3, ct); + await (await wal.AppendFramesAndCommitAsync( + new[] { new WalFrameWrite(0, page0), new WalFrameWrite(1, page1) }, 3, ct)).WaitAsync(ct); + await (await wal.AppendFramesAndCommitAsync( + new[] { new WalFrameWrite(1, replacedPage1) }, 3, ct)).WaitAsync(ct); + + // An indexed invalid frame triggers repair. An invalid older + // version is absent from the latest-page map, but recovery + // would still encounter it if finalization left it on disk. + int corruptFrame = corruptSupersededFrame ? 1 : 2; + using (var handle = File.OpenHandle(dbPath + ".wal", FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite)) + { + long offset = PageConstants.WalHeaderSize + (long)corruptFrame * PageConstants.WalFrameSize; + byte[] value = new byte[1]; + Assert.Equal(1, RandomAccess.Read(handle, value, offset)); + value[0] ^= 0x80; + RandomAccess.Write(handle, value, offset); + RandomAccess.FlushToDisk(handle); + } + + await wal.CheckpointAsync(device, 3, ct, allowFinalize: false); + Assert.True(wal.IsCheckpointCopyComplete); + Assert.True(wal.TryGetCheckpointRetainedWalStartOffset(out long retainedStart)); + Assert.Equal(PageConstants.WalHeaderSize + 3L * PageConstants.WalFrameSize, retainedStart); + + await (await wal.AppendFramesAndCommitAsync( + new[] { new WalFrameWrite(2, retainedPage2) }, 3, ct)).WaitAsync(ct); + await wal.CheckpointAsync(device, 3, ct); + Assert.False(wal.HasPendingCheckpoint); + Assert.Equal(1, wal.Index.FrameCount); + } + + // Reopen before copying the retained commit into the database. + // It must remain recoverable despite the discarded invalid prefix. + await using (var device = new FileStorageDevice(dbPath)) + await using (var reopened = new WriteAheadLog(dbPath, new WalIndex())) + { + await reopened.OpenAsync(3, ct); + Assert.Equal(1, reopened.Index.FrameCount); + await AssertWalPagesAsync(reopened, new() { [2] = retainedPage2 }, ct); + byte[] actual = new byte[PageConstants.PageSize]; + Assert.Equal(PageConstants.PageSize, await device.ReadAsync(0, actual, ct)); + Assert.Equal(page0, actual); + Assert.Equal(PageConstants.PageSize, await device.ReadAsync(PageConstants.PageSize, actual, ct)); + Assert.Equal(corruptSupersededFrame ? replacedPage1 : page1, actual); + } + } + finally + { + if (File.Exists(dbPath + ".wal")) File.Delete(dbPath + ".wal"); + if (File.Exists(dbPath)) File.Delete(dbPath); + } + } + + [Theory] + [InlineData(16)] + [InlineData(17)] + [InlineData(127)] + [InlineData(128)] + [InlineData(129)] + [InlineData(257)] + public async Task Checkpoint_RetainedSuffixAcrossMoveChunks_PreservesCommitsAfterReopen(int retainedFrameCount) + { + var ct = TestContext.Current.CancellationToken; + string dbPath = Path.Combine(Path.GetTempPath(), $"csharpdb_checkpoint_move_{Guid.NewGuid():N}.db"); + try + { + await using var device = new MemoryStorageDevice(); + uint pageCount = (uint)retainedFrameCount - 1; + var original = new Dictionary { [0] = CreatePage(0, 1), [1] = CreatePage(1, 1) }; + var expected = Enumerable.Range(0, (int)pageCount) + .ToDictionary(i => (uint)i, i => CreatePage((uint)i, 2)); + + await using (var wal = new WriteAheadLog(dbPath, new WalIndex())) + { + await wal.OpenAsync(2, ct); + await (await wal.AppendFramesAndCommitAsync( + original.Select(p => new WalFrameWrite(p.Key, p.Value)).ToArray(), 2, ct)).WaitAsync(ct); + await wal.CheckpointAsync(device, 2, ct, allowFinalize: false); + + // The small copied prefix makes source and destination overlap + // during larger moves. The last commit replaces a prior page; + // its marker may sit in a partial final chunk. + await (await wal.AppendFramesAndCommitAsync( + expected.Select(p => new WalFrameWrite(p.Key, p.Value)).ToArray(), pageCount, ct)).WaitAsync(ct); + expected[0] = CreatePage(0, 3); + await (await wal.AppendFramesAndCommitAsync( + new[] { new WalFrameWrite(0, expected[0]) }, pageCount, ct)).WaitAsync(ct); + + long generationBeforeCompaction = wal.Index.CommitCounter; + await wal.CheckpointAsync(device, pageCount, ct); + Assert.False(wal.HasPendingCheckpoint); + Assert.Equal(retainedFrameCount, wal.Index.FrameCount); + Assert.Equal(generationBeforeCompaction + 2, wal.Index.CommitCounter); + Assert.Equal(PageConstants.WalHeaderSize + (long)retainedFrameCount * PageConstants.WalFrameSize, + new FileInfo(dbPath + ".wal").Length); + await AssertPagesAsync(device, original, ct); + await AssertWalPagesAsync(wal, expected, ct); + } + + await using (var reopened = new WriteAheadLog(dbPath, new WalIndex())) + { + await reopened.OpenAsync(2, ct); + Assert.Equal(retainedFrameCount, reopened.Index.FrameCount); + Assert.Equal(2L, reopened.Index.CommitCounter); + await AssertWalPagesAsync(reopened, expected, ct); + + // A new append must start after the relocated suffix. + expected[pageCount] = CreatePage(pageCount, 4); + await (await reopened.AppendFramesAndCommitAsync( + new[] { new WalFrameWrite(pageCount, expected[pageCount]) }, pageCount + 1, ct)).WaitAsync(ct); + await reopened.CheckpointAsync(device, pageCount + 1, ct); + Assert.Equal(0, reopened.Index.FrameCount); + await AssertPagesAsync(device, expected, ct); + } + } + finally + { + if (File.Exists(dbPath + ".wal")) File.Delete(dbPath + ".wal"); + if (File.Exists(dbPath)) File.Delete(dbPath); + } + } + + private static async Task AssertWalPagesAsync( + WriteAheadLog wal, Dictionary expected, CancellationToken ct) + { + foreach (var (pageId, page) in expected) + { + Assert.True(wal.Index.TryGetLatest(pageId, out long offset)); + Assert.Equal(page, await wal.ReadPageAsync(offset, ct)); + } + } + + [Fact] + public async Task IncrementalCheckpoint_GrowingCommits_ResizeOnlyForTheirOwnCopy() + { + var ct = TestContext.Current.CancellationToken; + string dbPath = Path.Combine(Path.GetTempPath(), $"csharpdb_checkpoint_growth_length_{Guid.NewGuid():N}.db"); + try + { + await using var device = new MemoryStorageDevice(); + await using var wal = new WriteAheadLog(dbPath, new WalIndex()); + const uint originalPageCount = 35; + await wal.OpenAsync(originalPageCount, ct); + var expected = Enumerable.Range(0, (int)originalPageCount) + .ToDictionary(i => (uint)i, i => CreatePage((uint)i, 1)); + await (await wal.AppendFramesAndCommitAsync( + expected.Select(p => new WalFrameWrite(p.Key, p.Value)).ToArray(), originalPageCount, ct)).WaitAsync(ct); + await wal.CheckpointStepAsync(device, originalPageCount, 10, ct, allowFinalize: false); + + for (uint pageId = originalPageCount; pageId < originalPageCount + 2; pageId++) + { + byte[] page = CreatePage(pageId, 2); + expected[pageId] = page; + expected[0] = CreatePage(0, (int)pageId); + await (await wal.AppendFramesAndCommitAsync( + new[] { new WalFrameWrite(pageId, page), new WalFrameWrite(0, expected[0]) }, pageId + 1, ct)).WaitAsync(ct); + await wal.CheckpointStepAsync(device, pageId + 1, 10, ct, allowFinalize: false); + Assert.Equal((long)originalPageCount * PageConstants.PageSize, device.Length); + } + + uint finalPageCount = originalPageCount + 2; + await wal.CheckpointAsync(device, finalPageCount, ct, allowFinalize: false); + await wal.CheckpointAsync(device, finalPageCount, ct); + Assert.Equal((long)originalPageCount * PageConstants.PageSize, device.Length); + Assert.True(wal.Index.FrameCount > 0); + await wal.CheckpointAsync(device, finalPageCount, ct); + Assert.Equal((long)finalPageCount * PageConstants.PageSize, device.Length); + await AssertPagesAsync(device, expected, ct); + Assert.Equal(0, wal.Index.FrameCount); + } + finally + { + if (File.Exists(dbPath)) File.Delete(dbPath); + if (File.Exists(dbPath + ".wal")) File.Delete(dbPath + ".wal"); + } + } + + [Theory] + [InlineData(0, 0)] + [InlineData(15, 0)] + [InlineData(16, 0)] + [InlineData(34, 0)] + [InlineData(16, 1)] + [InlineData(16, 2)] + public async Task Checkpoint_StaleIndexAtValidationBatchBoundary_RebuildsCommittedPages( + int stalePage, int invalidOffsetKind) + { + var ct = TestContext.Current.CancellationToken; + string dbPath = Path.Combine(Path.GetTempPath(), $"csharpdb_checkpoint_validation_{Guid.NewGuid():N}.db"); + try + { + await using var device = new MemoryStorageDevice(); + var index = new WalIndex(); + await using var wal = new WriteAheadLog(dbPath, index); + const int pageCount = 35; + await wal.OpenAsync(pageCount, ct); + var expected = Enumerable.Range(0, pageCount).ToDictionary(i => (uint)i, i => CreatePage((uint)i, 1)); + await (await wal.AppendFramesAndCommitAsync( + expected.Select(entry => new WalFrameWrite(entry.Key, entry.Value)).ToArray(), pageCount, ct)).WaitAsync(ct); + + long staleOffset = invalidOffsetKind switch + { + 1 => PageConstants.WalHeaderSize + (long)stalePage * PageConstants.WalFrameSize + 1, + 2 => PageConstants.WalHeaderSize + (long)pageCount * PageConstants.WalFrameSize, + _ => PageConstants.WalHeaderSize + (long)((stalePage + 1) % pageCount) * PageConstants.WalFrameSize, + }; + index.AddCommittedFrame((uint)stalePage, staleOffset); + await wal.CheckpointAsync(device, pageCount, ct, allowFinalize: false); + Assert.True(wal.IsCheckpointCopyComplete); + Assert.Equal(pageCount, index.FrameCount); + await AssertPagesAsync(device, expected, ct); + } + finally + { + if (File.Exists(dbPath + ".wal")) File.Delete(dbPath + ".wal"); + if (File.Exists(dbPath)) File.Delete(dbPath); + } + } + + [Theory] + [InlineData(15, 0)] + [InlineData(16, 8)] + [InlineData(34, 16)] + public async Task Checkpoint_InvalidHeaderInValidationBatch_RetainsOnlyCompleteValidCommit( + int invalidPage, int headerByte) + { + var ct = TestContext.Current.CancellationToken; + string dbPath = Path.Combine(Path.GetTempPath(), $"csharpdb_checkpoint_corrupt_validation_{Guid.NewGuid():N}.db"); + try + { + await using var device = new MemoryStorageDevice(); + var index = new WalIndex(); + await using var wal = new WriteAheadLog(dbPath, index); + const int pageCount = 35; + await wal.OpenAsync(pageCount, ct); + var expected = Enumerable.Range(0, pageCount).ToDictionary(i => (uint)i, i => CreatePage((uint)i, 1)); + await (await wal.AppendFramesAndCommitAsync( + expected.Select(entry => new WalFrameWrite(entry.Key, entry.Value)).ToArray(), pageCount, ct)).WaitAsync(ct); + await (await wal.AppendFramesAndCommitAsync( + expected.Keys.Select(page => new WalFrameWrite(page, CreatePage(page, 2))).ToArray(), pageCount, ct)).WaitAsync(ct); + + using (var handle = File.OpenHandle(dbPath + ".wal", FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite)) + { + long offset = PageConstants.WalHeaderSize + (long)(pageCount + invalidPage) * PageConstants.WalFrameSize + headerByte; + byte[] value = new byte[1]; + Assert.Equal(1, RandomAccess.Read(handle, value, offset)); + value[0] ^= 0x80; + RandomAccess.Write(handle, value, offset); + RandomAccess.FlushToDisk(handle); + } + + await wal.CheckpointAsync(device, pageCount, ct, allowFinalize: false); + Assert.True(wal.IsCheckpointCopyComplete); + Assert.Equal(pageCount, index.FrameCount); + await AssertPagesAsync(device, expected, ct); + } + finally + { + if (File.Exists(dbPath + ".wal")) File.Delete(dbPath + ".wal"); + if (File.Exists(dbPath)) File.Delete(dbPath); + } + } + [Theory] [InlineData(1, false, false)] [InlineData(16, false, false)] diff --git a/tests/CSharpDB.Tests/WalTests.cs b/tests/CSharpDB.Tests/WalTests.cs index beba401e..b7053d0f 100644 --- a/tests/CSharpDB.Tests/WalTests.cs +++ b/tests/CSharpDB.Tests/WalTests.cs @@ -8,6 +8,7 @@ using CSharpDB.Storage.Paging; using CSharpDB.Storage.StorageEngine; using CSharpDB.Storage.Wal; +using CSharpDB.Storage.Transactions; using Microsoft.Win32.SafeHandles; using System.Reflection; @@ -595,7 +596,7 @@ public async Task DeferredCheckpoint_PartiallyCopiesWhileReaderIsActive_AndFinal } [Fact] - public async Task Checkpoint_WithReaderHoldingNoWalFrames_CanFinalizeImmediately() + public async Task Checkpoint_WithReaderHoldingNoWalFrames_DefersCopyAndPreservesSnapshotView() { var ct = TestContext.Current.CancellationToken; @@ -614,21 +615,176 @@ public async Task Checkpoint_WithReaderHoldingNoWalFrames_CanFinalizeImmediately _db = await Database.OpenAsync(_dbPath, options, ct); await _db.ExecuteAsync("CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT)", ct); + await _db.ExecuteAsync("INSERT INTO t VALUES (1, 'original')", ct); await _db.CheckpointAsync(ct); string walPath = _dbPath + ".wal"; - using (_db.CreateReaderSession()) + using (var reader = _db.CreateReaderSession()) { - await _db.ExecuteAsync("INSERT INTO t VALUES (1, 'reader-holds-no-wal')", ct); + await _db.ExecuteAsync("UPDATE t SET val = 'changed' WHERE id = 1", ct); + await _db.ExecuteAsync("INSERT INTO t VALUES (2, 'new row')", ct); await _db.CheckpointAsync(ct); - Assert.Equal(PageConstants.WalHeaderSize, new FileInfo(walPath).Length); + Assert.True(new FileInfo(walPath).Length > PageConstants.WalHeaderSize); + await using var old = await reader.ExecuteReadAsync("SELECT id, val FROM t ORDER BY id", ct); + DbValue[] row = Assert.Single(await old.ToListAsync(ct)); + Assert.Equal(1L, row[0].AsInteger); + Assert.Equal("original", row[1].AsText); } + await _db.CheckpointAsync(ct); + Assert.Equal(PageConstants.WalHeaderSize, new FileInfo(walPath).Length); await using var result = await _db.ExecuteAsync("SELECT COUNT(*) FROM t", ct); var rows = await result.ToListAsync(ct); - Assert.Equal(1L, rows[0][0].AsInteger); + Assert.Equal(2L, rows[0][0].AsInteger); + } + + [Theory(Timeout = 30_000)] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task Checkpoint_CopiesOnlyWhenEveryReaderHasWalVersionsOfChangedPages( + bool fileBacked, bool snapshotCoversChangedPage) + { + CancellationToken ct = TestContext.Current.CancellationToken; + await _db.DisposeAsync(); + var options = new DatabaseOptions + { + StorageEngineOptions = new StorageEngineOptions + { + PagerOptions = new PagerOptions { CheckpointPolicy = new FrameCountCheckpointPolicy(10_000) }, + }, + }; + _db = fileBacked + ? await Database.OpenAsync(_dbPath, options, ct) + : await Database.OpenInMemoryAsync(options, ct); + await _db.ExecuteAsync("CREATE TABLE a (id INTEGER PRIMARY KEY, val INTEGER)", ct); + await _db.ExecuteAsync("CREATE TABLE b (id INTEGER PRIMARY KEY, val INTEGER)", ct); + await _db.ExecuteAsync("INSERT INTO a VALUES (1, 10)", ct); + await _db.ExecuteAsync("INSERT INTO b VALUES (1, 20)", ct); + await _db.CheckpointAsync(ct); + await _db.ExecuteAsync("UPDATE a SET val = 11 WHERE id = 1", ct); + if (snapshotCoversChangedPage) + await _db.ExecuteAsync("UPDATE b SET val = 21 WHERE id = 1", ct); + + using var reader = _db.CreateReaderSession(); + var snapshot = (WalSnapshot)typeof(Database.ReaderSession) + .GetField("_snapshot", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(reader)!; + uint changedRoot = GetTableTree(_db, "b").RootPageId; + Assert.True(snapshot.HasWalFrames); + Assert.Equal(snapshotCoversChangedPage, snapshot.TryGet(changedRoot, out _)); + await _db.ExecuteAsync("UPDATE b SET val = 22 WHERE id = 1", ct); + await _db.CheckpointAsync(ct); + + var pager = (Pager)typeof(Database).GetField("_pager", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(_db)!; + var wal = (IWriteAheadLog)typeof(Pager).GetField("_wal", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(pager)!; + Assert.True(wal.HasPendingCheckpoint); + Assert.Equal(snapshotCoversChangedPage, wal.IsCheckpointCopyComplete); + // Delay the first access to b until after checkpointing: a shared cache cannot mask a replaced base page. + await using (var old = await reader.ExecuteReadAsync("SELECT val FROM b WHERE id = 1", ct)) + Assert.Equal(snapshotCoversChangedPage ? 21L : 20L, Assert.Single(await old.ToListAsync(ct))[0].AsInteger); + await using (var unchanged = await reader.ExecuteReadAsync("SELECT val FROM a WHERE id = 1", ct)) + Assert.Equal(11L, Assert.Single(await unchanged.ToListAsync(ct))[0].AsInteger); + await using (var latest = await _db.ExecuteAsync("SELECT val FROM b WHERE id = 1", ct)) + Assert.Equal(22L, Assert.Single(await latest.ToListAsync(ct))[0].AsInteger); + + reader.Dispose(); + await _db.CheckpointAsync(ct); + Assert.False(wal.HasPendingCheckpoint); + } + + [Fact(Timeout = 30_000)] + public async Task BackgroundCheckpoint_WithEmptySnapshot_DefersCopyAndCatchesUpAfterReaderDrains() + { + CancellationToken ct = TestContext.Current.CancellationToken; + await _db.DisposeAsync(); + var options = new DatabaseOptions + { + StorageEngineOptions = new StorageEngineOptions + { + PagerOptions = new PagerOptions + { + CheckpointPolicy = new FrameCountCheckpointPolicy(10_000), + AutoCheckpointExecutionMode = AutoCheckpointExecutionMode.Background, + }, + }, + }; + _db = await Database.OpenAsync(_dbPath, options, ct); + await _db.ExecuteAsync("CREATE TABLE t (id INTEGER PRIMARY KEY, val INTEGER)", ct); + await _db.ExecuteAsync("INSERT INTO t VALUES (1, 10)", ct); + await _db.CheckpointAsync(ct); + var pager = (Pager)typeof(Database).GetField("_pager", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(_db)!; + var wal = (IWriteAheadLog)typeof(Pager).GetField("_wal", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(pager)!; + pager.CheckpointPolicy = new FrameCountCheckpointPolicy(1); + pager.CheckpointThreshold = 1; + using (var reader = _db.CreateReaderSession()) + { + for (int value = 11; value <= 14; value++) + await _db.ExecuteAsync($"UPDATE t SET val = {value} WHERE id = 1", ct); + // Automatic attempts do not prepare a blocked copy on every commit. + Assert.False(wal.HasPendingCheckpoint); + await using var result = await reader.ExecuteReadAsync("SELECT val FROM t WHERE id = 1", ct); + Assert.Equal(10L, Assert.Single(await result.ToListAsync(ct))[0].AsInteger); + } + + await WaitForWalLengthAsync(_dbPath + ".wal", PageConstants.WalHeaderSize, TimeSpan.FromSeconds(5), ct); + await using var latest = await _db.ExecuteAsync("SELECT val FROM t WHERE id = 1", ct); + Assert.Equal(14L, Assert.Single(await latest.ToListAsync(ct))[0].AsInteger); + } + + [Theory(Timeout = 15_000)] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + public async Task BackgroundCheckpoint_LastReaderDrainBeforeActionReturns_IsNotLostOrRetriedAfterFailure( + bool failFirstAction, bool stopBeforeReturn) + { + CancellationToken ct = TestContext.Current.CancellationToken; + using var coordinator = new CheckpointCoordinator(); + var index = new WalIndex(); + WalSnapshot reader = coordinator.AcquireReaderSnapshot(index); + var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var firstReturn = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int passes = 0; + async ValueTask RunAsync(CancellationToken ignored) + { + if (Interlocked.Increment(ref passes) == 1) + { + firstStarted.TrySetResult(); + await firstReturn.Task.WaitAsync(ct); + if (failFirstAction) + throw new IOException("Injected checkpoint failure."); + } + else + { + coordinator.ClearDeferredCheckpointRequest(); + } + } + + coordinator.RequestDeferredCheckpoint(); + Assert.True(coordinator.TryStartBackgroundCheckpoint(RunAsync)); + try + { + await firstStarted.Task.WaitAsync(TimeSpan.FromSeconds(5), ct); + Assert.True(coordinator.ReleaseReaderSnapshot(reader)); + Assert.False(coordinator.TryStartBackgroundCheckpoint(RunAsync)); + Task? stopped = stopBeforeReturn ? coordinator.StopAndWaitForBackgroundCheckpointAsync().AsTask() : null; + firstReturn.TrySetResult(); + if (failFirstAction) + await Assert.ThrowsAsync(() => coordinator.WaitForBackgroundCheckpointAsync(ct).AsTask()); + else if (stopped is not null) + await stopped.WaitAsync(TimeSpan.FromSeconds(5), ct); + else + await coordinator.WaitForBackgroundCheckpointAsync(ct); + Assert.Equal(failFirstAction || stopBeforeReturn ? 1 : 2, Volatile.Read(ref passes)); + } + finally + { + firstReturn.TrySetResult(); + coordinator.ReleaseReaderSnapshot(reader); + } } [Fact] @@ -830,6 +986,187 @@ public async Task BackgroundAutoCheckpoint_BlockedByReader_RunsAfterReaderDrains Assert.Equal(1L, rows[0][0].AsInteger); } + [Theory] + [InlineData(false, false, 2)] + [InlineData(true, false, 2)] + [InlineData(false, false, 4)] + [InlineData(true, false, 4)] + [InlineData(false, true, 1)] + [InlineData(true, true, 1)] + public async Task BackgroundAutoCheckpoint_AdmitsWaitingWriterAfterCommitCopyBudget( + bool explicitWriter, bool bulkCommit, int maxPagesPerStep) + { + var ct = TestContext.Current.CancellationToken; + string dbPath = Path.Combine(Path.GetTempPath(), $"csharpdb_wal_checkpoint_writer_admission_{Guid.NewGuid():N}.db"); + var interceptor = new SliceCheckpointInterceptor(); + var options = new PagerOptions + { + CheckpointPolicy = new FrameCountCheckpointPolicy(bulkCommit ? 1 : 10000), + AutoCheckpointExecutionMode = AutoCheckpointExecutionMode.Background, + AutoCheckpointMaxPagesPerStep = maxPagesPerStep, + Interceptors = [interceptor], + }; + try + { + await using var pager = await OpenPagerAsync(dbPath, options, createNew: true, ct); + await pager.BeginTransactionAsync(ct); + uint root = await BTree.CreateNewAsync(pager, ct); + var tree = new BTree(pager, root); + for (int i = 1; i <= 100; i++) + await tree.InsertAsync(i, new byte[160], ct); + await pager.CommitAsync(ct); + + if (!bulkCommit) + { + // One slice covers this small commit but leaves older pages. + pager.CheckpointPolicy = new FrameCountCheckpointPolicy(1); + pager.CheckpointThreshold = 1; + await pager.BeginTransactionAsync(ct); + await tree.InsertAsync(101, new byte[160], ct); + await pager.CommitAsync(ct); + } + await interceptor.FirstStarted.Task.WaitAsync(TimeSpan.FromSeconds(10), ct); + + Task? explicitBegin = explicitWriter + ? pager.BeginWriteTransactionAsync(ct).AsTask() : null; + Task writer = explicitBegin is not null ? explicitBegin : pager.BeginTransactionAsync(ct).AsTask(); + try + { + Assert.False(writer.IsCompleted); + interceptor.FirstRelease.TrySetResult(); + Task winner = await Task.WhenAny(writer, interceptor.SecondStarted.Task).WaitAsync(TimeSpan.FromSeconds(10), ct); + bool copyMustContinue = bulkCommit || maxPagesPerStep == 4; + Assert.Same(copyMustContinue ? interceptor.SecondStarted.Task : writer, winner); + if (copyMustContinue) + Assert.False(writer.IsCompleted); + } + finally + { + interceptor.FirstRelease.TrySetResult(); + interceptor.SecondRelease.TrySetResult(); + await writer.WaitAsync(TimeSpan.FromSeconds(10), ct); + if (explicitBegin is not null) + await (await explicitBegin).DisposeAsync(); + else + await pager.RollbackAsync(ct); + } + } + finally + { + interceptor.FirstRelease.TrySetResult(); + interceptor.SecondRelease.TrySetResult(); + if (File.Exists(dbPath)) File.Delete(dbPath); + if (File.Exists(dbPath + ".wal")) File.Delete(dbPath + ".wal"); + } + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task BackgroundAutoCheckpoint_InterleavedGrowth_PreservesHeaderAndPolicy( + bool preserveOwnedPages, bool retainedBelowThreshold) + { + var ct = TestContext.Current.CancellationToken; + string dbPath = Path.Combine(Path.GetTempPath(), $"csharpdb_wal_checkpoint_growth_{Guid.NewGuid():N}.db"); + var interceptor = new SliceCheckpointInterceptor(blockThird: true, firstSlice: 2, thirdSlice: 8); + var options = new PagerOptions + { + CheckpointPolicy = new FrameCountCheckpointPolicy(10000), + AutoCheckpointExecutionMode = AutoCheckpointExecutionMode.Background, + AutoCheckpointMaxPagesPerStep = 1, + PreserveOwnedPagesOnCheckpoint = preserveOwnedPages, + Interceptors = [interceptor], + }; + var expected = new Dictionary(); + try + { + await using (var pager = await OpenPagerAsync(dbPath, options, createNew: true, ct)) + { + Task? followingWriter = null; + try + { + await pager.BeginTransactionAsync(ct); + for (int i = 1; i <= 6; i++) + { + uint pageId = await pager.AllocatePageAsync(ct); + (await pager.GetPageAsync(pageId, ct))[0] = expected[pageId] = (byte)i; + await pager.MarkDirtyAsync(pageId, ct); + } + await pager.CommitAsync(ct); + pager.CheckpointPolicy = new FrameCountCheckpointPolicy(1); + pager.CheckpointThreshold = 1; + await pager.BeginTransactionAsync(ct); + (await pager.GetPageAsync(1, ct))[0] = expected[1] = 99; + await pager.MarkDirtyAsync(1, ct); + await pager.CommitAsync(ct); + await interceptor.FirstStarted.Task.WaitAsync(TimeSpan.FromSeconds(10), ct); + + if (retainedBelowThreshold) + { + pager.CheckpointPolicy = new FrameCountCheckpointPolicy(1000); + pager.CheckpointThreshold = 1000; + } + Task writer = pager.BeginTransactionAsync(ct).AsTask(); + interceptor.FirstRelease.TrySetResult(); + await writer.WaitAsync(TimeSpan.FromSeconds(10), ct); + for (int i = 1; i <= 8; i++) + { + uint pageId = await pager.AllocatePageAsync(ct); + (await pager.GetPageAsync(pageId, ct))[0] = expected[pageId] = (byte)(30 + i); + await pager.MarkDirtyAsync(pageId, ct); + } + await pager.CommitAsync(ct); + uint expectedPageCount = pager.PageCount; + uint expectedChangeCounter = pager.ChangeCounter; + await interceptor.SecondStarted.Task.WaitAsync(TimeSpan.FromSeconds(10), ct); + if (retainedBelowThreshold) + followingWriter = pager.BeginTransactionAsync(ct).AsTask(); + interceptor.SecondRelease.TrySetResult(); + if (followingWriter is not null) + { + Task winner = await Task.WhenAny(followingWriter, interceptor.ThirdStarted.Task) + .WaitAsync(TimeSpan.FromSeconds(10), ct); + Assert.Same(followingWriter, winner); + } + else + { + await interceptor.ThirdStarted.Task.WaitAsync(TimeSpan.FromSeconds(10), ct); + } + + // The old copy is complete; newer page 0 is still in the WAL. + Assert.Equal(expectedPageCount, pager.PageCount); + Assert.Equal(expectedChangeCounter, pager.ChangeCounter); + } + finally + { + interceptor.FirstRelease.TrySetResult(); + interceptor.SecondRelease.TrySetResult(); + interceptor.ThirdRelease.TrySetResult(); + if (followingWriter is not null) + { + await followingWriter.WaitAsync(TimeSpan.FromSeconds(10), ct); + await pager.RollbackAsync(ct); + } + } + await pager.CheckpointAsync(ct); + } + await using var reopened = await OpenPagerAsync(dbPath, new PagerOptions(), createNew: false, ct); + Assert.Equal((uint)expected.Count + 1, reopened.PageCount); + foreach (var page in expected) + Assert.Equal(page.Value, (await reopened.GetPageAsync(page.Key, ct))[0]); + } + finally + { + interceptor.FirstRelease.TrySetResult(); + interceptor.SecondRelease.TrySetResult(); + interceptor.ThirdRelease.TrySetResult(); + if (File.Exists(dbPath)) File.Delete(dbPath); + if (File.Exists(dbPath + ".wal")) File.Delete(dbPath + ".wal"); + } + } + [Fact] public async Task BackgroundAutoCheckpoint_LargeCommit_CompletesRemainingSlicesWhileIdle() { @@ -2669,6 +3006,235 @@ public async Task CheckpointProgressRuntimeSnapshot_TracksCopyAndNewerCommits( } } + [Theory(Timeout = 30_000)] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task Checkpoint_PreservesCommitsPublishedAfterItsMapWasCaptured(bool fileBacked, bool corruptInitialMap) + { + CancellationToken ct = TestContext.Current.CancellationToken; + string path = Path.Combine(Path.GetTempPath(), $"csharpdb_checkpoint_cutoff_{Guid.NewGuid():N}.db"); + var index = new WalIndex(); + await using var device = new MemoryStorageDevice(); + IWriteAheadLog wal = fileBacked ? new WriteAheadLog(path, index) : new MemoryWriteAheadLog(index); + try + { + await wal.OpenAsync(3, ct); + await (await wal.AppendFramesAndCommitAsync( + new[] { new WalFrameWrite(0, CreateFilledPage(0x11)), new WalFrameWrite(1, CreateFilledPage(0x12)) }, 3, ct)).WaitAsync(ct); + index.AdvanceCommit(); // A counter-only advance must not extend the committed byte prefix. + if (corruptInitialMap) + { + index.OverwriteCommittedState(new Dictionary + { + [0] = PageConstants.WalHeaderSize + PageConstants.WalFrameSize, + [1] = PageConstants.WalHeaderSize + PageConstants.WalFrameSize, + }, frameCount: 2, commitCounter: index.CommitCounter); + } + + int publications = 0; + async ValueTask PublishLaterCommitAsync() + { + if (wal is WriteAheadLog file) file.CheckpointPlanCapturedForTests = null; + if (wal is MemoryWriteAheadLog memory) memory.CheckpointPlanCapturedForTests = null; + publications++; + await (await wal.AppendFramesAndCommitAsync( + new[] { new WalFrameWrite(1, CreateFilledPage(0x21)), new WalFrameWrite(2, CreateFilledPage(0x22)) }, 3, ct)).WaitAsync(ct); + } + if (wal is WriteAheadLog fileWal) fileWal.CheckpointPlanCapturedForTests = PublishLaterCommitAsync; + if (wal is MemoryWriteAheadLog memoryWal) memoryWal.CheckpointPlanCapturedForTests = PublishLaterCommitAsync; + + if (corruptInitialMap) + { + // Repair of the old prefix must not overwrite a newer publication or truncate it. + await Assert.ThrowsAsync(() => wal.CheckpointAsync(device, 3, ct, allowFinalize: false).AsTask()); + Assert.Equal(4, index.FrameCount); + Assert.True(index.TryGetLatest(2, out long newest)); + Assert.All(await wal.ReadPageAsync(newest, ct), value => Assert.Equal((byte)0x22, value)); + await wal.CheckpointAsync(device, 3, ct); + await AssertPageFilledAsync(device, 0, 0x11, ct); + await AssertPageFilledAsync(device, 1, 0x21, ct); + await AssertPageFilledAsync(device, 2, 0x22, ct); + } + else + { + await wal.CheckpointAsync(device, 3, ct, allowFinalize: false); + Assert.True(wal.TryGetCheckpointRetainedWalStartOffset(out long retainedStart)); + Assert.Equal(PageConstants.WalHeaderSize + 2L * PageConstants.WalFrameSize, retainedStart); + await AssertPageFilledAsync(device, 1, 0x12, ct); + await wal.CheckpointAsync(device, 3, ct); + Assert.Equal(2, index.FrameCount); + Assert.True(index.TryGetLatest(1, out long retainedFirst)); + Assert.True(index.TryGetLatest(2, out long retainedLast)); + Assert.All(await wal.ReadPageAsync(retainedFirst, ct), value => Assert.Equal((byte)0x21, value)); + Assert.All(await wal.ReadPageAsync(retainedLast, ct), value => Assert.Equal((byte)0x22, value)); + await wal.CheckpointAsync(device, 3, ct); + await AssertPageFilledAsync(device, 1, 0x21, ct); + await AssertPageFilledAsync(device, 2, 0x22, ct); + } + Assert.Equal(1, publications); + } + finally + { + await wal.CloseAndDeleteAsync(); + await wal.DisposeAsync(); + if (File.Exists(path)) File.Delete(path); + if (File.Exists(path + ".wal")) File.Delete(path + ".wal"); + } + } + + [Theory(Timeout = 30_000)] + [InlineData(false)] + [InlineData(true)] + public async Task Checkpoint_RepairPreservesUnpublishedSuffixAndDiscardsOnlyItsCapturedPrefix(bool corruptLastCommit) + { + CancellationToken ct = TestContext.Current.CancellationToken; + string path = Path.Combine(Path.GetTempPath(), $"csharpdb_checkpoint_repair_suffix_{Guid.NewGuid():N}.db"); + try + { + var index = new WalIndex(); + await using var device = new MemoryStorageDevice(); + await using var wal = new WriteAheadLog(path, index); + await wal.OpenAsync(3, ct); + await (await wal.AppendFramesAndCommitAsync( + new[] { new WalFrameWrite(0, CreateFilledPage(0x11)), new WalFrameWrite(1, CreateFilledPage(0x12)) }, 3, ct)).WaitAsync(ct); + await (await wal.AppendFramesAndCommitAsync( + new[] { new WalFrameWrite(1, CreateFilledPage(0x22)) }, 3, ct)).WaitAsync(ct); + long publishedTail = PageConstants.WalHeaderSize + 3L * PageConstants.WalFrameSize; + + if (corruptLastCommit) + { + using var handle = File.OpenHandle(path + ".wal", FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite); + long headerOffset = PageConstants.WalHeaderSize + 2L * PageConstants.WalFrameSize; + byte[] value = new byte[1]; + Assert.Equal(1, RandomAccess.Read(handle, value, headerOffset)); + value[0] ^= 0x80; + RandomAccess.Write(handle, value, headerOffset); + RandomAccess.FlushToDisk(handle); + } + else + { + // Public index corruption also inflates FrameCount. It must + // not extend the independently recorded published boundary. + index.AddCommittedFrame(0, publishedTail); + } + + wal.CheckpointPlanCapturedForTests = async () => + { + wal.CheckpointPlanCapturedForTests = null; + wal.BeginTransaction(); + // The batch API writes frames immediately without publishing + // them; the single-frame API may only stage them in memory. + await wal.AppendFramesAsync(new[] { new WalFrameWrite(2, CreateFilledPage(0x33)) }, ct); + }; + await wal.CheckpointAsync(device, 3, ct, allowFinalize: false); + Assert.True(wal.IsCheckpointCopyComplete); + Assert.True(wal.TryGetCheckpointRetainedWalStartOffset(out long retainedStart)); + Assert.Equal(publishedTail, retainedStart); + Assert.Equal(corruptLastCommit ? 2 : 3, index.FrameCount); + Assert.False(index.TryGetLatest(2, out _)); + Assert.True(new FileInfo(path + ".wal").Length >= publishedTail + PageConstants.WalFrameSize); + await AssertPageFilledAsync(device, 0, 0x11, ct); + await AssertPageFilledAsync(device, 1, corruptLastCommit ? (byte)0x12 : (byte)0x22, ct); + + // Finalization remains after publication. The formerly pending + // suffix must survive compaction and the following checkpoint. + await (await wal.CommitAsync(3, ct)).WaitAsync(ct); + await wal.CheckpointAsync(device, 3, ct); + Assert.Equal(1, index.FrameCount); + Assert.True(index.TryGetLatest(2, out long survivingOffset)); + Assert.All(await wal.ReadPageAsync(survivingOffset, ct), value => Assert.Equal((byte)0x33, value)); + await wal.CheckpointAsync(device, 3, ct); + await AssertPageFilledAsync(device, 2, 0x33, ct); + Assert.Equal(0, index.FrameCount); + } + finally + { + if (File.Exists(path + ".wal")) File.Delete(path + ".wal"); + if (File.Exists(path)) File.Delete(path); + } + } + + [Theory(Timeout = 30_000)] + [InlineData(false)] + [InlineData(true)] + public async Task Checkpoint_RepairWithNoSurvivingCommit_FinalizesAfterWalReaderDrains(bool throughPager) + { + CancellationToken ct = TestContext.Current.CancellationToken; + string path = Path.Combine(Path.GetTempPath(), $"csharpdb_checkpoint_empty_repair_{Guid.NewGuid():N}.db"); + try + { + await using var device = new FileStorageDevice(path, createNew: true); + var index = new WalIndex(); + await using var wal = new WriteAheadLog(path, index); + await using var pager = await Pager.CreateAsync(device, wal, index, + new PagerOptions { CheckpointPolicy = new FrameCountCheckpointPolicy(10_000) }, ct); + await pager.InitializeNewDatabaseAsync(ct); + await pager.RecoverAsync(ct); + byte[] originalHeader = (await pager.GetPageAsync(0, ct)).ToArray(); + WalSnapshot emptyReader = pager.AcquireReaderSnapshot(); + WalSnapshot? walReader = null; + try + { + Assert.False(emptyReader.HasWalFrames); + await (await wal.AppendFramesAndCommitAsync( + new[] { new WalFrameWrite(0, originalHeader) }, pager.PageCount, ct)).WaitAsync(ct); + walReader = pager.AcquireReaderSnapshot(); + Assert.True(walReader.HasWalFrames); + using (var handle = File.OpenHandle(path + ".wal", FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite)) + { + byte[] value = new byte[1]; + Assert.Equal(1, RandomAccess.Read(handle, value, PageConstants.WalHeaderSize)); + value[0] ^= 0x80; + RandomAccess.Write(handle, value, PageConstants.WalHeaderSize); + RandomAccess.FlushToDisk(handle); + } + + if (throughPager) + await pager.CheckpointAsync(ct); + else + await wal.CheckpointAsync(device, pager.PageCount, ct, allowFinalize: false); + + Assert.Equal(0, index.FrameCount); + Assert.True(wal.HasPendingCheckpoint); + Assert.True(wal.IsCheckpointCopyComplete); + Assert.True(wal.TryGetCheckpointRetainedWalStartOffset(out long discardedEnd)); + Assert.Equal(PageConstants.WalHeaderSize + PageConstants.WalFrameSize, discardedEnd); + Assert.True(new FileInfo(path + ".wal").Length > PageConstants.WalHeaderSize); + + pager.ReleaseReaderSnapshot(walReader); + walReader = null; + if (throughPager) + await pager.CheckpointAsync(ct); + else + await wal.CheckpointAsync(device, pager.PageCount, ct); + + Assert.False(wal.HasPendingCheckpoint); + Assert.Equal(PageConstants.WalHeaderSize, new FileInfo(path + ".wal").Length); + // A reader with an empty WAL map can remain: no base page was + // copied and its original header must still be visible. + using var old = pager.CreateSnapshotReader(emptyReader); + Assert.Equal(originalHeader, await old.GetPageAsync(0, ct)); + byte[] storedHeader = new byte[PageConstants.PageSize]; + Assert.Equal(PageConstants.PageSize, await device.ReadAsync(0, storedHeader, ct)); + Assert.Equal(originalHeader, storedHeader); + await pager.CheckpointAsync(ct); + Assert.False(wal.HasPendingCheckpoint); + } + finally + { + if (walReader is not null) + pager.ReleaseReaderSnapshot(walReader); + pager.ReleaseReaderSnapshot(emptyReader); + } + } + finally + { + if (File.Exists(path + ".wal")) File.Delete(path + ".wal"); + if (File.Exists(path)) File.Delete(path); + } + } + [Fact] public async Task FileWriteAheadLog_Checkpoint_RepairsStaleIndexOffsetsFromWalFile() { @@ -3041,7 +3607,7 @@ await runtime.Wal.CheckpointAsync( } [Fact] - public async Task RuntimeSnapshot_ReaderWithNoWalFramesDoesNotReportAwaitingReaders() + public async Task RuntimeSnapshot_ReaderWithNoWalFramesRetainsUncopiedWalUntilItDrains() { var ct = TestContext.Current.CancellationToken; var runtime = await OpenMemoryRuntimePagerAsync( @@ -3052,6 +3618,7 @@ public async Task RuntimeSnapshot_ReaderWithNoWalFramesDoesNotReportAwaitingRead }, ct); await using Pager pager = runtime.Pager; + byte[] originalHeader = (await pager.GetPageAsync(0, ct)).ToArray(); WalSnapshot reader = pager.AcquireReaderSnapshot(); try @@ -3061,15 +3628,19 @@ public async Task RuntimeSnapshot_ReaderWithNoWalFramesDoesNotReportAwaitingRead await pager.CheckpointAsync(ct); PagerRuntimeRawSnapshot snapshot = GetPagerRuntimeSnapshot(pager); - Assert.Equal(StorageCheckpointPhaseRaw.Idle, snapshot.Wal.CheckpointPhase); + Assert.NotEqual(StorageCheckpointPhaseRaw.Idle, snapshot.Wal.CheckpointPhase); Assert.Equal(1, snapshot.Storage.ActiveReaderCount); - Assert.Equal(0, snapshot.Wal.FrameCount); - Assert.Equal(0, snapshot.Wal.RetainedBytes); + Assert.True(snapshot.Wal.FrameCount > 0); + Assert.False(runtime.Wal.IsCheckpointCopyComplete); + using var old = pager.CreateSnapshotReader(reader); + Assert.Equal(originalHeader, await old.GetPageAsync(0, ct)); } finally { pager.ReleaseReaderSnapshot(reader); } + await pager.CheckpointAsync(ct); + Assert.Equal(StorageCheckpointPhaseRaw.Idle, GetPagerRuntimeSnapshot(pager).Wal.CheckpointPhase); } [Fact] @@ -3886,6 +4457,35 @@ private static async ValueTask OpenPagerAsync( return pager; } + private sealed class SliceCheckpointInterceptor( + bool blockThird = false, int firstSlice = 1, int thirdSlice = 3) : IPageOperationInterceptor + { + private int _slice; + public TaskCompletionSource FirstStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource FirstRelease { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource SecondStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource SecondRelease { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource ThirdStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource ThirdRelease { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public ValueTask OnCheckpointStartAsync(int committedFrameCount, CancellationToken ct = default) + { + int slice = Interlocked.Increment(ref _slice); + if (slice == firstSlice) { FirstStarted.TrySetResult(); return new ValueTask(FirstRelease.Task.WaitAsync(ct)); } + if (slice == firstSlice + 1) { SecondStarted.TrySetResult(); return new ValueTask(SecondRelease.Task.WaitAsync(ct)); } + if (slice == thirdSlice && blockThird) { ThirdStarted.TrySetResult(); return new ValueTask(ThirdRelease.Task.WaitAsync(ct)); } + return ValueTask.CompletedTask; + } + public ValueTask OnBeforeReadAsync(uint pageId, CancellationToken ct = default) => ValueTask.CompletedTask; + public ValueTask OnAfterReadAsync(uint pageId, PageReadSource source, CancellationToken ct = default) => ValueTask.CompletedTask; + public ValueTask OnBeforeWriteAsync(uint pageId, CancellationToken ct = default) => ValueTask.CompletedTask; + public ValueTask OnAfterWriteAsync(uint pageId, bool succeeded, CancellationToken ct = default) => ValueTask.CompletedTask; + public ValueTask OnCommitStartAsync(int dirtyPageCount, CancellationToken ct = default) => ValueTask.CompletedTask; + public ValueTask OnCommitEndAsync(int dirtyPageCount, bool succeeded, CancellationToken ct = default) => ValueTask.CompletedTask; + public ValueTask OnCheckpointEndAsync(int committedFrameCount, bool succeeded, CancellationToken ct = default) => ValueTask.CompletedTask; + public ValueTask OnRecoveryStartAsync(CancellationToken ct = default) => ValueTask.CompletedTask; + public ValueTask OnRecoveryEndAsync(bool succeeded, CancellationToken ct = default) => ValueTask.CompletedTask; + } + private sealed class BlockingCheckpointInterceptor : IPageOperationInterceptor { private readonly TaskCompletionSource _checkpointStarted = diff --git a/tests/CSharpDB.WalPrototype.CrashHarness/CSharpDB.WalPrototype.CrashHarness.csproj b/tests/CSharpDB.WalPrototype.CrashHarness/CSharpDB.WalPrototype.CrashHarness.csproj new file mode 100644 index 00000000..a00f60bd --- /dev/null +++ b/tests/CSharpDB.WalPrototype.CrashHarness/CSharpDB.WalPrototype.CrashHarness.csproj @@ -0,0 +1,19 @@ + + + Exe + net10.0 + enable + enable + false + false + + + + + + + + + + + diff --git a/tests/CSharpDB.WalPrototype.CrashHarness/Program.cs b/tests/CSharpDB.WalPrototype.CrashHarness/Program.cs new file mode 100644 index 00000000..e4db3f6e --- /dev/null +++ b/tests/CSharpDB.WalPrototype.CrashHarness/Program.cs @@ -0,0 +1,51 @@ +using System.Diagnostics; +using System.Text.Json; +using CSharpDB.WalPrototype.Tests; + +if (args.Length != 4) throw new ArgumentException("Expected operation, crash point, scratch directory, and new marker path."); +string operation = args[0], target = args[1], directory = Path.GetFullPath(args[2]), marker = Path.GetFullPath(args[3]); +bool armed = operation == "recover"; +void Point(string name) +{ + if (!armed || name != target) return; + using (var stream = new FileStream(marker, FileMode.CreateNew, FileAccess.Write, FileShare.Read)) + { + JsonSerializer.Serialize(stream, new { operation, point = name, pid = Environment.ProcessId, + walLength = new FileInfo(Path.Combine(directory, "database.wal")).Length }); + stream.Flush(true); + } + // Kill only this explicitly created worker, with no graceful store disposal. + Process.GetCurrentProcess().Kill(); + Thread.Sleep(Timeout.Infinite); +} +byte[] Page(byte value) => Enumerable.Repeat(value, ControlCodec.PageSize).ToArray(); +using var store = ExperimentalWalStore.Open(directory, point: Point); +switch (operation) +{ + case "commit": + armed = true; + store.Commit([new(0, Page(50)), new(1, Page(60))], 3); + Point("after-commit-return"); + break; + case "copy": + var copying = store.CaptureCheckpoint(); + armed = true; + store.CopyCheckpoint(copying); + break; + case "retire": + var retiring = store.CaptureCheckpoint(); + store.CopyCheckpoint(retiring); + store.Commit([new(2, Page(31))], 3); + armed = true; + store.RetireCheckpoint(retiring); + break; + case "reset": + armed = true; + store.Reset(); + break; + case "recover": + Point("after-recovery-return"); + break; + default: throw new ArgumentException("Unknown operation."); +} +throw new InvalidOperationException("Requested crash point was not reached."); diff --git a/tests/CSharpDB.WalPrototype.Tests/CSharpDB.WalPrototype.Tests.csproj b/tests/CSharpDB.WalPrototype.Tests/CSharpDB.WalPrototype.Tests.csproj new file mode 100644 index 00000000..045a28bb --- /dev/null +++ b/tests/CSharpDB.WalPrototype.Tests/CSharpDB.WalPrototype.Tests.csproj @@ -0,0 +1,23 @@ + + + + Exe + net10.0 + enable + enable + false + false + + + + + + + + + + + + + + diff --git a/tests/CSharpDB.WalPrototype.Tests/CommittedTailCrashTests.cs b/tests/CSharpDB.WalPrototype.Tests/CommittedTailCrashTests.cs new file mode 100644 index 00000000..166f70e8 --- /dev/null +++ b/tests/CSharpDB.WalPrototype.Tests/CommittedTailCrashTests.cs @@ -0,0 +1,93 @@ +namespace CSharpDB.WalPrototype.Tests; + +public sealed class CommittedTailCrashTests +{ + [Theory] + [InlineData("commit", "frame-0-header-written", "old")] + [InlineData("commit", "frame-0-half-written", "old")] + [InlineData("commit", "frame-0-written", "old")] + [InlineData("commit", "frame-1-header-written", "old")] + [InlineData("commit", "frame-1-half-written", "old")] + [InlineData("commit", "frame-1-written", "old")] + [InlineData("commit", "commit-marker-half-written", "old")] + [InlineData("commit", "commit-marker-written", "old")] + [InlineData("commit", "commit-marker-flushed", "old")] + [InlineData("commit", "control-active-0-written", "new")] + [InlineData("commit", "control-active-0-flushed", "new")] + [InlineData("commit", "control-active-1-written", "new")] + [InlineData("commit", "control-active-1-flushed", "new")] + [InlineData("commit", "after-commit-return", "new")] + [InlineData("copy", "base-page-0-written", "old")] + [InlineData("copy", "base-page-1-written", "old")] + [InlineData("copy", "base-flushed", "old")] + [InlineData("retire", "control-active-0-written", "suffix")] + [InlineData("retire", "control-active-0-flushed", "suffix")] + [InlineData("retire", "control-active-1-written", "suffix")] + [InlineData("retire", "control-active-1-flushed", "suffix")] + [InlineData("retire", "floor-published", "suffix")] + [InlineData("reset", "base-page-0-written", "old")] + [InlineData("reset", "base-flushed", "old")] + [InlineData("reset", "control-empty-0-written", "old")] + [InlineData("reset", "control-empty-0-flushed", "old")] + [InlineData("reset", "control-empty-1-written", "old")] + [InlineData("reset", "control-empty-1-flushed", "old")] + [InlineData("reset", "truncate-written", "old")] + [InlineData("reset", "truncate-flushed", "old")] + [InlineData("reset", "control-active-0-written", "old")] + [InlineData("reset", "control-active-0-flushed", "old")] + [InlineData("reset", "control-active-1-written", "old")] + [InlineData("reset", "control-active-1-flushed", "old")] + [InlineData("reset", "reset-complete", "old")] + public async Task CommittedTail_RecoversAllSelectedCrashPoints(string operation, string point, string expected) + { + string directory = Scratch.Create("tail-process-" + point); + using (var store = CommittedTailTests.Create(directory)) WalStorageTests.Seed(store); + await WalProcessCrashTests.KillAt(directory, operation, point, "crash"); + WalProcessCrashTests.PreserveBeforeRecovery(directory, "before"); + using (var recovered = ExperimentalWalStore.Open(directory)) + { + WalStorageTests.AssertPages(recovered, expected == "new" ? (byte)50 : (byte)12, + expected == "new" ? (byte)60 : (byte)21, expected == "suffix" ? (byte)31 : (byte)30); + recovered.Commit([new(2, WalStorageTests.Page(32))], 3, TestContext.Current.CancellationToken); + } + using (var reopened = ExperimentalWalStore.Open(directory)) + WalStorageTests.AssertPages(reopened, expected == "new" ? (byte)50 : (byte)12, + expected == "new" ? (byte)60 : (byte)21, 32); + Scratch.WriteJson(Path.Combine(directory, "tail-process-result.json"), new { operation, point, expected, kills = 1, status = "passed" }); + } + + [Theory] + [InlineData("control-empty-1-flushed")] + [InlineData("truncate-written")] + [InlineData("truncate-flushed")] + [InlineData("control-active-0-flushed")] + [InlineData("control-active-1-flushed")] + [InlineData("after-recovery-return")] + public async Task InterruptedResetRecovery_PreservesCommittedTailAcrossGeneration(string recoveryPoint) + { + string directory = Scratch.Create("tail-process-recovery"); + using (var store = CommittedTailTests.Create(directory)) WalStorageTests.Seed(store); + await WalProcessCrashTests.KillAt(directory, "reset", "control-empty-0-flushed", "first"); + WalProcessCrashTests.PreserveBeforeRecovery(directory, "after-first"); + await WalProcessCrashTests.KillAt(directory, "recover", recoveryPoint, "second"); + WalProcessCrashTests.PreserveBeforeRecovery(directory, "after-second"); + using (var recovered = ExperimentalWalStore.Open(directory)) + { + WalStorageTests.AssertPages(recovered, 12, 21, 30); + Assert.Equal(ControlCodec.DataStart, recovered.PhysicalLength); + recovered.Commit([new(2, WalStorageTests.Page(31))], 3, TestContext.Current.CancellationToken); + } + for (int lost = 0; lost < 2; lost++) + { + string copy = Path.Combine(directory, "lost-" + lost); Directory.CreateDirectory(copy); + byte[] wal = File.ReadAllBytes(WalStorageTests.WalPath(directory)); + wal.AsSpan(lost * ControlCodec.SlotSize, ControlCodec.SlotSize).Clear(); + File.WriteAllBytes(WalStorageTests.WalPath(copy), wal); + File.Copy(WalStorageTests.BasePath(directory), WalStorageTests.BasePath(copy)); + using var reopened = ExperimentalWalStore.Open(copy); + WalStorageTests.AssertPages(reopened, 12, 21, 31); + } + Scratch.WriteJson(Path.Combine(directory, "tail-process-result.json"), new + { operation = "repeated-recovery", point = recoveryPoint, expected = "old-then-new-append", kills = 2, slotLossCases = 2, status = "passed" }); + } +} diff --git a/tests/CSharpDB.WalPrototype.Tests/CommittedTailTests.cs b/tests/CSharpDB.WalPrototype.Tests/CommittedTailTests.cs new file mode 100644 index 00000000..8e635ae5 --- /dev/null +++ b/tests/CSharpDB.WalPrototype.Tests/CommittedTailTests.cs @@ -0,0 +1,211 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; + +namespace CSharpDB.WalPrototype.Tests; + +public sealed class CommittedTailTests +{ + internal static ExperimentalWalStore Create(string directory, Action? point = null, long budget = ExperimentalWalStore.DefaultBudget) => + ExperimentalWalStore.Create(directory, [WalStorageTests.Page(10), WalStorageTests.Page(20), WalStorageTests.Page(30)], + budget, point, committedTail: true); + + [Fact] + public void OptInField_IsChecksummedAndCannotBeMixedWithFloorOnlySlots() + { + var record = ControlCodecTests.Record() with { CommittedTail = ControlCodec.DataStart + 2L * ControlCodec.FrameSize }; + byte[] bytes = ControlCodec.Encode(record); + Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(8))); + Assert.Equal((ulong)record.CommittedTail!.Value, BinaryPrimitives.ReadUInt64LittleEndian(bytes.AsSpan(96))); + Assert.Equal(record, ControlCodec.Decode(bytes, record.CommittedTail.Value)); + for (int i = 96; i < 104; i++) + { + bytes[i] ^= 1; + Assert.Null(ControlCodec.Decode(bytes, record.CommittedTail.Value)); + bytes[i] ^= 1; + } + Assert.Throws(() => ControlCodec.Select( + ControlCodecTests.Header(record, ControlCodecTests.Record(2)), record.CommittedTail.Value)); + Assert.Throws(() => ControlCodec.Select(ControlCodecTests.Header(record, + record with { Sequence = 2, CommittedTail = ControlCodec.DataStart }), record.CommittedTail.Value)); + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(8), 0); + SHA256.HashData(bytes.AsSpan(0, ControlCodec.DigestOffset), bytes.AsSpan(ControlCodec.DigestOffset)); + Assert.Throws(() => ControlCodec.Decode(bytes, record.CommittedTail.Value)); + } + + [Theory] + [InlineData(0UL)] + [InlineData(8191UL)] + [InlineData(8193UL)] + [InlineData(12312UL)] + [InlineData(9223372036854775808UL)] + [InlineData(18446744073709551615UL)] + public void ImpossibleChecksummedTail_IsFatalEvenWithUsablePeer(ulong tail) + { + var record = ControlCodecTests.Record() with { CommittedTail = ControlCodec.DataStart }; + byte[] header = ControlCodecTests.Header(record, record with { Sequence = 2 }); + BinaryPrimitives.WriteUInt64LittleEndian(header.AsSpan(96), tail); + SHA256.HashData(header.AsSpan(0, ControlCodec.DigestOffset), header.AsSpan(ControlCodec.DigestOffset, 32)); + Assert.Throws(() => ControlCodec.Select(header, ControlCodec.DataStart)); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + public void AcknowledgedCommit_SurvivesOneLostControlAndLaterTornAppend(int lostSlot) + { + string directory = Scratch.Create("tail-slot-loss"); + GenerationSalt salt; + using (var store = Create(directory)) { WalStorageTests.Seed(store); salt = store.Salt; } + byte[] wal = File.ReadAllBytes(WalStorageTests.WalPath(directory)); + long acknowledged = wal.Length; + byte[] torn = WalFrameCodec.Encode(new(2, WalStorageTests.Page(31)), 0, salt)[..2072]; + wal = [.. wal, .. torn]; + wal.AsSpan(lostSlot * ControlCodec.SlotSize, ControlCodec.SlotSize).Clear(); + File.WriteAllBytes(WalStorageTests.WalPath(directory), wal); + using var recovered = ExperimentalWalStore.Open(directory); + WalStorageTests.AssertPages(recovered, 12, 21, 30); + Assert.Equal(acknowledged, recovered.PhysicalLength); + } + + [Theory] + [InlineData("payload")] + [InlineData("truncated")] + [InlineData("marker-reverted")] + [InlineData("boundary-inside-batch")] + public void DamageInsideDeclaredCommit_IsRejectedWithoutMutation(string damage) + { + string directory = Scratch.Create("tail-committed-damage"); + using (var store = Create(directory)) store.Commit([new(0, WalStorageTests.Page(11)), new(1, WalStorageTests.Page(21))], 3, TestContext.Current.CancellationToken); + byte[] wal = File.ReadAllBytes(WalStorageTests.WalPath(directory)); + var record = ControlCodec.Select(wal.AsSpan(0, ControlCodec.DataStart), wal.Length).Current; + if (damage == "payload") wal[ControlCodec.DataStart + 24] ^= 1; + else if (damage == "truncated") Array.Resize(ref wal, wal.Length - 1); + else if (damage == "marker-reverted") + { + byte[] uncommitted = WalFrameCodec.Encode(new(1, WalStorageTests.Page(21)), 0, record.Salt); + uncommitted.AsSpan(0, 24).CopyTo(wal.AsSpan(wal.Length - ControlCodec.FrameSize)); + } + else + { + var inside = record with { CommittedTail = ControlCodec.DataStart + ControlCodec.FrameSize }; + ControlCodecTests.Header(inside, inside with { Sequence = inside.Sequence + 1 }).CopyTo(wal, 0); + } + File.WriteAllBytes(WalStorageTests.WalPath(directory), wal); + WalStorageTests.AssertRejectedUnchanged(directory); + } + + [Fact] + public void CancellationCannotTruncateAfterCommitBoundaryPublicationStarts() + { + string directory = Scratch.Create("tail-cancel-publication"); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + bool armed = false; + using (var store = Create(directory, name => + { + if (armed && name == "control-active-0-written") + { cancellation.Cancel(); throw new OperationCanceledException(cancellation.Token); } + })) + { + WalStorageTests.Seed(store); armed = true; + Assert.Throws(() => store.Commit([new(2, WalStorageTests.Page(31))], 3, cancellation.Token)); + Assert.Equal(0, store.ReservedBytes); + Assert.Throws(() => store.Read(0)); + } + // The call did not return success, but its complete published batch may be recovered. + using var recovered = ExperimentalWalStore.Open(directory); + WalStorageTests.AssertPages(recovered, 12, 21, 31); + } + + [Fact] + public void RetirementGrowthResetAndReaderBackpressure_KeepTailInCurrentGeneration() + { + string directory = Scratch.Create("tail-generations"); + long budget = ControlCodec.DataStart + 4L * ControlCodec.FrameSize; + using (var store = Create(directory, budget: budget)) + { + WalStorageTests.Seed(store); + var capture = store.CaptureCheckpoint(); store.CopyCheckpoint(capture); + store.Commit([new(3, WalStorageTests.Page(40))], 4, TestContext.Current.CancellationToken); + store.RetireCheckpoint(capture); + using var reader = store.BeginRead(); + Assert.Throws(() => store.Commit([new(2, WalStorageTests.Page(31))], 4, TestContext.Current.CancellationToken)); + Assert.Equal(WalStorageTests.Page(40), reader.Read(3)); + reader.Dispose(); + store.Commit([new(2, WalStorageTests.Page(31))], 4, TestContext.Current.CancellationToken); + Assert.Equal((ulong)2, store.Epoch); + } + using var recovered = ExperimentalWalStore.Open(directory, budget); + WalStorageTests.AssertPages(recovered, 12, 21, 31, 40); + recovered.Reset(); + Assert.Equal(ControlCodec.DataStart, recovered.PublishedTail); + } + + [Fact] + public void CommitPublicationFlushesDataThenEachControlBeforeReturning() + { + string directory = Scratch.Create("tail-io-cost"); + List points = []; + using var store = Create(directory, points.Add); + points.Clear(); + store.Commit([new(0, WalStorageTests.Page(11))], 3, TestContext.Current.CancellationToken); + Assert.Equal(new[] { "commit-marker-flushed", "control-active-0-flushed", "control-active-1-flushed" }, + points.Where(p => p.EndsWith("-flushed", StringComparison.Ordinal))); + Assert.Equal(new[] { "control-active-0-written", "control-active-1-written" }, + points.Where(p => p.StartsWith("control-", StringComparison.Ordinal) && p.EndsWith("-written", StringComparison.Ordinal))); + Scratch.WriteJson(Path.Combine(directory, "commit-io-cost.json"), new + { durableWalFlushes = 3, extraFlushes = 2, extraControlBytesWritten = 8192, points }); + } + + [Fact] + public void InterruptedBoundaryPublication_NeverDropsAnAcknowledgedBatch() + { + string root = Scratch.Create("tail-control-cuts"); + string seed = Path.Combine(root, "seed"); + using (var store = Create(seed)) WalStorageTests.Seed(store); + byte[] oldWal = File.ReadAllBytes(WalStorageTests.WalPath(seed)); + byte[] database = File.ReadAllBytes(WalStorageTests.BasePath(seed)); + var control = ControlCodec.Select(oldWal.AsSpan(0, ControlCodec.DataStart), oldWal.Length).Current; + byte[] staged = [.. oldWal, .. WalFrameCodec.Encode(new(0, WalStorageTests.Page(50)), 0, control.Salt), + .. WalFrameCodec.Encode(new(1, WalStorageTests.Page(60)), 3, control.Salt)]; + string traceRoot = Path.Combine(root, "trace"); Directory.CreateDirectory(traceRoot); + using var trace = new TraceFile(traceRoot, staged, database); + var protocol = new ControlProtocol(trace); + protocol.PrepareForWrites(); protocol.PublishCommittedTail(staged.Length); + trace.Dispose(); + int oldRecovered = 0, newRecovered = 0, refused = 0; + List cases = []; + foreach (var image in trace.Images) + foreach (int lost in new[] { -1, 0, 1 }) + { + string directory = Path.Combine(root, $"case-{cases.Count:D4}"); Directory.CreateDirectory(directory); + byte[] bytes = (byte[])image.Wal.Clone(); + if (lost >= 0) bytes.AsSpan(lost * ControlCodec.SlotSize, ControlCodec.SlotSize).Clear(); + File.WriteAllBytes(WalStorageTests.WalPath(directory), bytes); + File.WriteAllBytes(WalStorageTests.BasePath(directory), database); + byte[]? selected = null; ulong sequence = 0; + for (int slot = 0; slot < 2; slot++) + { + var candidate = bytes.AsSpan(slot * ControlCodec.SlotSize, ControlCodec.SlotSize).ToArray(); + if (!SHA256.HashData(candidate.AsSpan(0, 4064)).AsSpan().SequenceEqual(candidate.AsSpan(4064))) continue; + ulong seq = BinaryPrimitives.ReadUInt64LittleEndian(candidate.AsSpan(48)); + if (seq >= sequence) { sequence = seq; selected = candidate; } + } + string expected; + if (selected is null) { WalStorageTests.AssertRejectedUnchanged(directory); refused++; expected = "refused"; } + else + { + long tail = checked((long)BinaryPrimitives.ReadUInt64LittleEndian(selected.AsSpan(96))); + Assert.True(tail == oldWal.Length || tail == staged.Length); + bool latest = tail == staged.Length; + using var recovered = ExperimentalWalStore.Open(directory); + WalStorageTests.AssertPages(recovered, latest ? (byte)50 : (byte)12, latest ? (byte)60 : (byte)21, 30); + Assert.Equal(tail, recovered.PhysicalLength); + if (latest) newRecovered++; else oldRecovered++; + expected = latest ? "new" : "old"; + } + cases.Add(new { image.Point, lost, expected, originalWalSha256 = Convert.ToHexString(SHA256.HashData(bytes)) }); + } + Scratch.WriteJson(Path.Combine(root, "tail-control-results.json"), new + { count = cases.Count, oldRecovered, newRecovered, refused, cases }); + } +} diff --git a/tests/CSharpDB.WalPrototype.Tests/ControlCodecTests.cs b/tests/CSharpDB.WalPrototype.Tests/ControlCodecTests.cs new file mode 100644 index 00000000..3470aa5c --- /dev/null +++ b/tests/CSharpDB.WalPrototype.Tests/ControlCodecTests.cs @@ -0,0 +1,201 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; + +namespace CSharpDB.WalPrototype.Tests; + +public sealed class ControlCodecTests +{ + internal static ControlRecord Record(ulong sequence = 1) => new(new(1, 2), sequence, 1, + new(3, 4), ControlMode.Active, ControlCodec.DataStart, 3); + + internal static byte[] Header(ControlRecord first, ControlRecord second) => + [.. ControlCodec.Encode(first), .. ControlCodec.Encode(second)]; + + [Fact] + public void CanonicalLayout_MatchesIndependentLittleEndianVector() + { + var record = new ControlRecord(new(0x0807060504030201, 0x1817161514131211), + 0x0102030405060708, 0x0102030405060707, new(0x11223344, 0x55667788), + ControlMode.Active, 16432, 0x12345678); + byte[] encoded = ControlCodec.Encode(record); + // Independently packed with Python struct + hashlib, not generated by this codec. + Assert.Equal("4357413202000000000000000010000000100000181000000020000000000000" + + "0102030405060708111213141516171808070605040302010707060504030201" + + "4433221188776655010000000000000030400000000000007856341200000000", + Convert.ToHexString(encoded.AsSpan(0, 96))); + Assert.Equal("B565E41231C80C2A740D6C1BC85BC4EC79684D0CB43D353BBBE8D99EDE1FC986", + Convert.ToHexString(encoded.AsSpan(ControlCodec.DigestOffset))); + Assert.Equal(record, ControlCodec.Decode(encoded, 16432)); + Assert.Equal(encoded, ControlCodec.Encode(ControlCodec.Decode(encoded, 16432)!)); + } + + [Fact] + public void EverySingleBitCorruption_IsUnavailable() + { + byte[] encoded = ControlCodec.Encode(Record()); + for (int i = 0; i < encoded.Length; i++) + for (int bit = 0; bit < 8; bit++) + { + encoded[i] ^= (byte)(1 << bit); + Assert.Null(ControlCodec.Decode(encoded, ControlCodec.DataStart)); + encoded[i] ^= (byte)(1 << bit); + } + } + + [Fact] + public void EveryPrefixAndSuffixTear_EitherDetectsTearOrReadsExactOldOrNewRecord() + { + var old = Record(); + var next = old with { Sequence = 19, Epoch = 2, Salt = new(7, 8), Mode = ControlMode.Empty }; + byte[] oldBytes = ControlCodec.Encode(old), newBytes = ControlCodec.Encode(next); + for (int cut = 0; cut <= ControlCodec.SlotSize; cut++) + { + byte[] prefix = (byte[])oldBytes.Clone(), suffix = (byte[])oldBytes.Clone(); + newBytes.AsSpan(0, cut).CopyTo(prefix); + newBytes.AsSpan(cut).CopyTo(suffix.AsSpan(cut)); + foreach (byte[] bytes in new[] { prefix, suffix }) + { + var decoded = ControlCodec.Decode(bytes, ControlCodec.DataStart); + Assert.True(decoded is null || decoded == old || decoded == next, $"cut={cut}"); + } + } + } + + [Fact] + public void All512ByteSectorSubsets_DetectTearOrReadExactRecord() + { + var old = Record(); + var next = old with { Sequence = 2, Floor = ControlCodec.DataStart + ControlCodec.FrameSize }; + byte[] oldBytes = ControlCodec.Encode(old), newBytes = ControlCodec.Encode(next); + for (int mask = 0; mask < 256; mask++) + { + byte[] bytes = (byte[])oldBytes.Clone(); + for (int sector = 0; sector < 8; sector++) + if ((mask & (1 << sector)) != 0) + newBytes.AsSpan(sector * 512, 512).CopyTo(bytes.AsSpan(sector * 512)); + var decoded = ControlCodec.Decode(bytes, next.Floor); + Assert.True(decoded is null || decoded == old || decoded == next, $"mask={mask}"); + } + } + + [Theory] + [InlineData(0)] // magic + [InlineData(4)] // version + [InlineData(8)] // flags + [InlineData(12)] // slot size + [InlineData(16)] // page size + [InlineData(20)] // frame size + [InlineData(24)] // data start + [InlineData(72)] // unknown mode (ACTIVE 1 XOR 0x80) + [InlineData(76)] // reserved word + [InlineData(92)] // reserved area + [InlineData(4063)] + public void ChecksummedUnsupportedSlot_NeverFallsBackToOtherSlot(int offset) + { + foreach (int badSlot in new[] { 0, 1 }) + { + byte[] header = Header(Record(1), Record(2)); + var slot = header.AsSpan(badSlot * ControlCodec.SlotSize, ControlCodec.SlotSize); + slot[offset] ^= 0x80; + Rehash(slot); + byte[] before = (byte[])header.Clone(); + Assert.Throws(() => ControlCodec.Select(header, ControlCodec.DataStart)); + Assert.Equal(before, header); + } + } + + [Theory] + [InlineData(0)] + [InlineData(8191)] + [InlineData(8193)] + [InlineData(12312)] // aligned but beyond physical extent + [InlineData(9223372036854775808UL)] + [InlineData(18446744073709551615UL)] + public void ChecksummedImpossibleFloor_IsFatalEvenInOlderSlot(ulong floor) + { + byte[] header = Header(Record(1), Record(2)); + BinaryPrimitives.WriteUInt64LittleEndian(header.AsSpan(80), floor); + Rehash(header.AsSpan(0, ControlCodec.SlotSize)); + Assert.Throws(() => ControlCodec.Select(header, ControlCodec.DataStart)); + } + + [Theory] + [InlineData(32, 16)] // identity + [InlineData(48, 8)] // sequence + [InlineData(56, 8)] // epoch + [InlineData(64, 4)] // first salt + [InlineData(68, 4)] // second salt + public void ChecksummedMissingRequiredField_IsFatal(int offset, int length) + { + byte[] header = Header(Record(1), Record(2)); + header.AsSpan(offset, length).Clear(); + Rehash(header.AsSpan(0, ControlCodec.SlotSize)); + Assert.Throws(() => ControlCodec.Select(header, ControlCodec.DataStart)); + } + + [Fact] + public void ConflictingIdentitiesSequencesSaltsOrRegressingState_AreFatal() + { + var first = Record(10) with { Floor = ControlCodec.DataStart + ControlCodec.FrameSize, Epoch = 2 }; + ControlRecord[] conflicts = + [ + first with { Sequence = 11, Identity = new(5, 6) }, + first with { PageCount = 99 }, + first with { Sequence = 11, Salt = new(5, 6) }, + first with { Sequence = 11, Floor = ControlCodec.DataStart }, + first with { Sequence = 11, Epoch = 1, Salt = new(5, 6) }, + first with { Sequence = 11, Epoch = 3 }, + first with { Sequence = 11, Mode = ControlMode.Empty, Floor = ControlCodec.DataStart } + ]; + foreach (var second in conflicts) + Assert.Throws(() => ControlCodec.Select(Header(first, second), first.Floor)); + Assert.True(ControlCodec.Select(Header(first, first), first.Floor).Mirrored); + } + + [Fact] + public void MissingSlotUsesPeer_BothMissingOrTruncatedControlAreaFail() + { + byte[] header = Header(Record(1), Record(2)); + foreach (int lost in new[] { 0, 1 }) + { + byte[] bytes = (byte[])header.Clone(); + bytes.AsSpan(lost * ControlCodec.SlotSize, ControlCodec.SlotSize).Clear(); + var selected = ControlCodec.Select(bytes, ControlCodec.DataStart); + Assert.Equal((ulong)(lost == 0 ? 2 : 1), selected.Current.Sequence); + Assert.False(selected.Mirrored); + } + Assert.Throws(() => ControlCodec.Select(new byte[8192], 8192)); + Assert.Throws(() => ControlCodec.Select(header.AsSpan(0, 4096), 4096)); + for (int length = 0; length < 4096; length++) + Assert.Null(ControlCodec.Decode(header.AsSpan(0, length), 8192)); + } + + [Fact] + public void EmptyCannotPointIntoFrames_AndTailNeedNotBeAligned() + { + Assert.Throws(() => ControlCodec.Encode(Record() with + { Mode = ControlMode.Empty, Floor = ControlCodec.DataStart + ControlCodec.FrameSize })); + var record = Record() with { Floor = ControlCodec.DataStart + ControlCodec.FrameSize }; + Assert.Equal(record, ControlCodec.Decode(ControlCodec.Encode(record), record.Floor + 13)); + var terminal = Record(ulong.MaxValue) with { Epoch = ulong.MaxValue, PageCount = uint.MaxValue }; + Assert.Equal(terminal, ControlCodec.Decode(ControlCodec.Encode(terminal), ControlCodec.DataStart)); + } + + [Fact] + public void GeneratedIdentitiesAndSalts_AreNonzeroAndNew() + { + var salt = new GenerationSalt(3, 4); + for (int i = 0; i < 100; i++) + { + Assert.NotEqual(default, FileIdentity.Create()); + var next = GenerationSalt.Create(salt); + Assert.NotEqual(salt, next); + Assert.NotEqual(0u, next.First); + Assert.NotEqual(0u, next.Second); + salt = next; + } + } + + private static void Rehash(Span bytes) => + SHA256.HashData(bytes[..ControlCodec.DigestOffset], bytes[ControlCodec.DigestOffset..]); +} diff --git a/tests/CSharpDB.WalPrototype.Tests/ControlProtocol.cs b/tests/CSharpDB.WalPrototype.Tests/ControlProtocol.cs new file mode 100644 index 00000000..13ab455b --- /dev/null +++ b/tests/CSharpDB.WalPrototype.Tests/ControlProtocol.cs @@ -0,0 +1,173 @@ +namespace CSharpDB.WalPrototype.Tests; + +internal interface IControlFile +{ + long Length { get; } + byte[] ReadHeader(); + void WriteSlot(int slot, byte[] bytes); + void Flush(); + void Truncate(); +} + +// Single-owner prototype. There is no engine, transaction, reader or frame parser here. +internal sealed class ControlProtocol +{ + private readonly IControlFile _file; + private ControlSelection _selection; + private bool _failed; + public bool WritersAdmitted { get; private set; } + public ControlRecord Current => _selection.Current; + + public ControlProtocol(IControlFile file) + { + _file = file; + _selection = ControlCodec.Select(file.ReadHeader(), file.Length); + } + + public void PrepareForWrites() => Run(() => + { + int repairWrites = _selection.Mirrored ? 0 : 1; + ReserveSequences(repairWrites + (Current.Mode == ControlMode.Empty ? 2 : 0)); + if (_selection.Mirrored) + { + // A process-only crash may leave readable, unflushed control bytes in the OS + // cache. Establish their durability before truncation or writer admission. + _file.Flush(); + } + else + RepairPeer(); // Its flush also makes the selected record durable. + if (Current.Mode == ControlMode.Empty) + { + _file.Truncate(); + _file.Flush(); + PublishBoth(Current with { Mode = ControlMode.Active }); + } + WritersAdmitted = true; + }); + + public void Retire(long floor, uint pageCount, Action copyAndFlushBase) => Run(() => + { + RequireAdmitted(); + ReserveSequences(2); + if (floor < Current.Floor) throw new InvalidDataException("Cannot move floor backwards."); + var next = Current with { Floor = floor, PageCount = pageCount }; + ControlCodec.Validate(next, _file.Length); + WritersAdmitted = false; + copyAndFlushBase(); + PublishBoth(next); + WritersAdmitted = true; + }); + + public void PublishCommittedTail(long tail) => Run(() => + { + RequireAdmitted(); + ReserveSequences(2); + if (Current.CommittedTail is not long previous || tail <= previous) + throw new InvalidOperationException("A committed-tail feature and advancing boundary are required."); + var next = Current with { CommittedTail = tail }; + ControlCodec.Validate(next, _file.Length); + WritersAdmitted = false; + // Establish the complete batch before publishing any record that calls it committed. + _file.Flush(); + PublishBoth(next); + WritersAdmitted = true; + }); + + public void Reset(uint pageCount, bool quiescent, Action copyAndFlushBase, + Func? newSalt = null) => Run(() => + { + RequireAdmitted(); + if (!quiescent) throw new InvalidOperationException("Reset requires no readers, transaction or pending commit."); + ReserveSequences(4); + ulong epoch = checked(Current.Epoch + 1); + // A terminal epoch is readable, but cannot admit writers that would need another reset. + if (epoch == ulong.MaxValue) throw new InvalidOperationException("Epoch exhausted."); + var salt = (newSalt ?? GenerationSalt.Create)(Current.Salt); + if (salt == Current.Salt || salt.First == 0 || salt.Second == 0) + throw new InvalidDataException("Reset needs fresh nonzero salts."); + var next = Current with { Epoch = epoch, Salt = salt, Mode = ControlMode.Empty, + Floor = ControlCodec.DataStart, PageCount = pageCount, + CommittedTail = Current.CommittedTail.HasValue ? ControlCodec.DataStart : null }; + WritersAdmitted = false; + copyAndFlushBase(); + PublishBoth(next); + _file.Truncate(); + _file.Flush(); + PublishBoth(Current with { Mode = ControlMode.Active }); + WritersAdmitted = true; + }); + + private void RepairPeer() + { + if (!_selection.Mirrored) + Publish(1 - _selection.CurrentSlot, Current); + } + + private void PublishBoth(ControlRecord record) + { + Publish(0, record); + Publish(1, record); + } + + private void Publish(int slot, ControlRecord record) + { + record = record with { Sequence = checked(Current.Sequence + 1) }; + _file.WriteSlot(slot, ControlCodec.Encode(record)); + _file.Flush(); + _selection = new(record, slot, slot == 0 ? record : _selection.First, + slot == 1 ? record : _selection.Second); + } + + private void ReserveSequences(int count) + { + if (Current.Epoch == ulong.MaxValue || Current.Sequence >= ulong.MaxValue - (ulong)count) + throw new InvalidOperationException("Control counters exhausted."); + } + + private void RequireAdmitted() + { + if (!WritersAdmitted || Current.Mode != ControlMode.Active) + throw new InvalidOperationException("Writer admission is closed."); + } + + private void Run(Action action) + { + if (_failed) throw new InvalidOperationException("Reopen after failed control I/O or validation."); + try { action(); } + catch + { + WritersAdmitted = false; + _failed = true; + throw; + } + } +} + +internal sealed class FileControlStore : IControlFile, IDisposable +{ + private readonly FileStream _stream; + public long Length => _stream.Length; + + public FileControlStore(string path) => _stream = new(path, FileMode.Open, FileAccess.ReadWrite, + FileShare.None, bufferSize: 1, FileOptions.RandomAccess); + + public byte[] ReadHeader() + { + byte[] header = new byte[(int)Math.Min(Length, ControlCodec.DataStart)]; + _stream.Position = 0; + _stream.ReadExactly(header); + return header; + } + + public void WriteSlot(int slot, byte[] bytes) + { + if (slot is < 0 or > 1 || bytes.Length != ControlCodec.SlotSize) + throw new ArgumentOutOfRangeException(nameof(slot)); + _stream.Position = slot * ControlCodec.SlotSize; + _stream.Write(bytes); + } + + public void Flush() => _stream.Flush(flushToDisk: true); + public void Truncate() => _stream.SetLength(ControlCodec.DataStart); + public void Dispose() => _stream.Dispose(); +} diff --git a/tests/CSharpDB.WalPrototype.Tests/ControlProtocolTests.cs b/tests/CSharpDB.WalPrototype.Tests/ControlProtocolTests.cs new file mode 100644 index 00000000..925b4056 --- /dev/null +++ b/tests/CSharpDB.WalPrototype.Tests/ControlProtocolTests.cs @@ -0,0 +1,279 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; + +namespace CSharpDB.WalPrototype.Tests; + +public sealed class ControlProtocolTests +{ + [Theory] + [InlineData("retire")] + [InlineData("retire-corrupt-superseded")] + [InlineData("retire-corrupt-indexed")] + [InlineData("reset")] + [InlineData("recover-empty-missing")] + [InlineData("recover-empty-stale")] + [InlineData("recover-active-empty")] + [InlineData("recover-active-missing")] + [InlineData("recover-active-retired-stale")] + public void InterruptedPublication_ReopensExactStateOrFailsClosedAsSpecified(string scenario) + { + string root = Scratch.Create(scenario); + var (wal, database, corruptOffset) = InitialState(scenario); + using var trace = new TraceFile(root, wal, database); + var protocol = new ControlProtocol(trace); + Assert.False(protocol.WritersAdmitted); + protocol.PrepareForWrites(); + if (scenario.StartsWith("retire", StringComparison.Ordinal)) + protocol.Retire(ControlCodec.DataStart + 2L * ControlCodec.FrameSize, 3, + () => trace.CopyAndFlushBase(PageFixture.RetiredPages)); + else if (scenario == "reset") + protocol.Reset(3, true, () => trace.CopyAndFlushBase(PageFixture.FinalPages), _ => new(7, 8)); + Assert.True(protocol.WritersAdmitted); + trace.Dispose(); + + int recovered = 0, rejected = 0; + List results = []; + foreach (var image in trace.Images) + foreach (int lostSlot in new[] { -1, 0, 1 }) + { + byte[] bytes = (byte[])image.Wal.Clone(); + if (lostSlot >= 0) bytes.AsSpan(lostSlot * ControlCodec.SlotSize, ControlCodec.SlotSize).Clear(); + string name = $"case-{results.Count:D4}"; + string walPath = Path.Combine(root, name + ".wal"), basePath = Path.Combine(root, name + ".base"); + File.WriteAllBytes(walPath, bytes); + File.WriteAllBytes(basePath, image.Database); + bool expectFailure = ExpectedFailure(bytes, corruptOffset); + bool failed = false; + try + { + using (var reopened = new FileControlStore(walPath)) + { + var recovery = new ControlProtocol(reopened); + Assert.False(recovery.WritersAdmitted); + } + Assert.Equal(PageFixture.Database(PageFixture.FinalPages), + PageFixture.Recover(File.ReadAllBytes(walPath), File.ReadAllBytes(basePath))); + recovered++; + } + catch (InvalidDataException) + { + failed = true; + rejected++; + } + Assert.True(expectFailure == failed, $"{scenario}: {image.Point}, lost={lostSlot}, expected failure={expectFailure}"); + Assert.Equal(bytes, File.ReadAllBytes(walPath)); + Assert.Equal(image.Database, File.ReadAllBytes(basePath)); + results.Add(new { name, image.Point, lostSlot, failed, + walSha256 = Convert.ToHexString(SHA256.HashData(bytes)), + baseSha256 = Convert.ToHexString(SHA256.HashData(image.Database)) }); + } + + // Successful admission must survive loss of either slot AFTER a fresh acknowledged append. + byte[] finalWal = File.ReadAllBytes(Path.Combine(root, "working.wal")); + byte[] finalBase = File.ReadAllBytes(Path.Combine(root, "working.base")); + var selected = ControlCodec.Select(finalWal.AsSpan(0, ControlCodec.DataStart), finalWal.Length); + Assert.True(selected.Mirrored); + Assert.Equal(ControlMode.Active, selected.Current.Mode); + for (int lost = 0; lost < 2; lost++) + { + byte[] appended = [.. finalWal, .. PageFixture.Frame(2, 31, selected.Current.Salt)]; + appended.AsSpan(lost * ControlCodec.SlotSize, ControlCodec.SlotSize).Clear(); + string path = Path.Combine(root, $"acknowledged-append-lost-{lost}.wal"); + File.WriteAllBytes(path, appended); + Assert.Equal(PageFixture.Database([12, 21, 31]), PageFixture.Recover(File.ReadAllBytes(path), finalBase)); + } + Scratch.WriteJson(Path.Combine(root, "results.json"), new + { scenario, recovered, rejected, crashCases = results.Count, acknowledgedAppendCases = 2, trace.Operations, cases = results }); + } + + [Fact] + public void ResetFlushOrder_ClosesAdmissionUntilBothActiveSlotsComplete() + { + string root = Scratch.Create("flush-order"); + using var trace = new TraceFile(root, PageFixture.Wal(), PageFixture.Database(PageFixture.InitialPages)); + var protocol = new ControlProtocol(trace); + protocol.PrepareForWrites(); + trace.OnOperation = _ => Assert.False(protocol.WritersAdmitted); + protocol.Reset(3, true, () => trace.CopyAndFlushBase(PageFixture.FinalPages), _ => new(7, 8)); + Assert.Equal(new[] { "flush-wal", "write-base", "flush-base", "write-slot-0", "flush-wal", "write-slot-1", + "flush-wal", "truncate", "flush-wal", "write-slot-0", "flush-wal", "write-slot-1", "flush-wal" }, trace.Operations); + Assert.True(protocol.WritersAdmitted); + Assert.Equal((ulong)6, protocol.Current.Sequence); + Assert.Equal((ulong)2, protocol.Current.Epoch); + Assert.Equal(ControlCodec.DataStart, trace.Length); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AnyResetIoFailure_StopsFurtherIoAndRequiresReopen(bool after) + { + for (int operation = 1; operation <= 12; operation++) + { + string root = Scratch.Create($"io-failure-{operation}-{after}"); + // Operation 1 is the recovery durability barrier, before the reset under test. + using var trace = new TraceFile(root, PageFixture.Wal(), PageFixture.Database(PageFixture.InitialPages), operation + 1, after); + var protocol = new ControlProtocol(trace); + protocol.PrepareForWrites(); + Assert.Throws(() => protocol.Reset(3, true, + () => trace.CopyAndFlushBase(PageFixture.FinalPages), _ => new(7, 8))); + Assert.False(protocol.WritersAdmitted); + Assert.Equal(operation + 1, trace.Operations.Count); + Assert.Throws(protocol.PrepareForWrites); + Assert.Equal(operation + 1, trace.Operations.Count); + trace.Dispose(); + byte[] wal = File.ReadAllBytes(Path.Combine(root, "working.wal")); + byte[] database = File.ReadAllBytes(Path.Combine(root, "working.base")); + Assert.Equal(PageFixture.Database(PageFixture.FinalPages), PageFixture.Recover(wal, database)); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void CounterExhaustion_IsReadableButCannotWrite(bool epoch) + { + var record = ControlCodecTests.Record(epoch ? 1 : ulong.MaxValue) with + { Epoch = epoch ? ulong.MaxValue : 1 }; + string root = Scratch.Create("counter-exhaustion"); + byte[] wal = ControlCodecTests.Header(record, record); + using var trace = new TraceFile(root, wal, PageFixture.Database(PageFixture.FinalPages)); + var protocol = new ControlProtocol(trace); + Assert.Equal(record, protocol.Current); + Assert.Throws(protocol.PrepareForWrites); + Assert.Empty(trace.Operations); + Assert.False(protocol.WritersAdmitted); + } + + [Theory] + [InlineData("sequences")] + [InlineData("epoch")] + [InlineData("salt")] + [InlineData("busy")] + [InlineData("floor")] + public void InvalidMaintenanceRequest_FailsBeforeBaseCopyOrWalIo(string kind) + { + var record = ControlCodecTests.Record(kind == "sequences" ? ulong.MaxValue - 4 : 1) with + { Epoch = kind == "epoch" ? ulong.MaxValue - 1 : 1 }; + byte[] wal = ControlCodecTests.Header(record, record); + using var trace = new TraceFile(Scratch.Create(kind), wal, PageFixture.Database(PageFixture.FinalPages)); + var protocol = new ControlProtocol(trace); + protocol.PrepareForWrites(); + int copies = 0; + Action action = kind == "floor" + ? () => protocol.Retire(ControlCodec.DataStart + 1, 3, () => copies++) + : () => protocol.Reset(3, kind != "busy", () => copies++, + old => kind == "salt" ? old : new(7, 8)); + if (kind is "floor" or "salt") Assert.Throws(action); + else Assert.Throws(action); + Assert.Equal(0, copies); + Assert.Equal(new[] { "flush-wal" }, trace.Operations); + Assert.False(protocol.WritersAdmitted); + } + + [Fact] + public void RecoveredMirroredControls_AreFlushedBeforeAdmissionOrDestructiveIo() + { + foreach (bool emptyMode in new[] { false, true }) + { + var record = ControlCodecTests.Record() with { Mode = emptyMode ? ControlMode.Empty : ControlMode.Active }; + byte[] wal = [.. ControlCodecTests.Header(record, record with { Sequence = 2 }), .. PageFixture.Wal()[8192..]]; + using var trace = new TraceFile(Scratch.Create("recovery-flush"), wal, PageFixture.Database(PageFixture.FinalPages)); + var protocol = new ControlProtocol(trace); + trace.OnOperation = _ => Assert.False(protocol.WritersAdmitted); + protocol.PrepareForWrites(); + Assert.Equal("flush-wal", trace.Operations[0]); + Assert.True(protocol.WritersAdmitted); + if (emptyMode) Assert.Equal("truncate", trace.Operations[1]); + else Assert.Single(trace.Operations); + + foreach (bool after in new[] { false, true }) + { + using var failing = new TraceFile(Scratch.Create("recovery-flush-failure"), wal, + PageFixture.Database(PageFixture.FinalPages), throwAt: 1, throwAfter: after); + var recovery = new ControlProtocol(failing); + Assert.Throws(recovery.PrepareForWrites); + Assert.False(recovery.WritersAdmitted); + Assert.Equal(new[] { "flush-wal" }, failing.Operations); + } + } + } + + [Fact] + public void NegativeOrderingControls_ExposeHiddenCommitAndUnusableBoundary() + { + var old = ControlCodecTests.Record(2); + var empty = old with { Sequence = 3, Epoch = 2, Salt = new(7, 8), Mode = ControlMode.Empty }; + var active = empty with { Sequence = 5, Mode = ControlMode.Active }; + // EMPTY before the base flush silently omits acknowledged WAL state. + byte[] prematureEmpty = [.. ControlCodecTests.Header(empty, empty with { Sequence = 4 }), .. PageFixture.Wal()[8192..]]; + Assert.False(PageFixture.Database(PageFixture.FinalPages).AsSpan().SequenceEqual( + PageFixture.Recover(prematureEmpty, PageFixture.Database(PageFixture.InitialPages)))); + // Admit a commit with only one ACTIVE, then lose it: surviving EMPTY hides that commit. + byte[] prematureAppend = [.. ControlCodecTests.Header(active, empty), .. PageFixture.Frame(2, 31, active.Salt)]; + prematureAppend.AsSpan(0, ControlCodec.SlotSize).Clear(); + Assert.False(PageFixture.Database([12, 21, 31]).AsSpan().SequenceEqual( + PageFixture.Recover(prematureAppend, PageFixture.Database(PageFixture.FinalPages)))); + // Truncate before mirrored EMPTY, then lose it: the old floor points past EOF. + byte[] prematureTruncate = ControlCodecTests.Header(empty, + old with { Floor = ControlCodec.DataStart + ControlCodec.FrameSize }); + prematureTruncate.AsSpan(0, ControlCodec.SlotSize).Clear(); + Assert.Throws(() => PageFixture.Recover(prematureTruncate, PageFixture.Database(PageFixture.FinalPages))); + // Declare retirement complete after one floor write; lose it and expose obsolete corruption. + byte[] prematureRetirement = PageFixture.Wal(); + ControlCodec.Encode(old with { Sequence = 3, Floor = ControlCodec.DataStart + 2L * ControlCodec.FrameSize }) + .CopyTo(prematureRetirement, 0); + prematureRetirement[ControlCodec.DataStart + 24] ^= 1; + prematureRetirement.AsSpan(0, ControlCodec.SlotSize).Clear(); + Assert.Throws(() => PageFixture.Recover(prematureRetirement, PageFixture.Database(PageFixture.RetiredPages))); + } + + private static (byte[] Wal, byte[] Database, int? CorruptOffset) InitialState(string scenario) + { + byte[] wal = PageFixture.Wal(), database = PageFixture.Database(PageFixture.InitialPages); + int? corruptOffset = null; + var empty = ControlCodecTests.Record(3) with { Mode = ControlMode.Empty, Epoch = 2, Salt = new(7, 8) }; + if (scenario.Contains("corrupt", StringComparison.Ordinal)) + { + corruptOffset = ControlCodec.DataStart + (scenario.EndsWith("indexed", StringComparison.Ordinal) ? ControlCodec.FrameSize : 0); + wal[corruptOffset.Value + 24] ^= 1; + } + else if (scenario.StartsWith("recover-empty", StringComparison.Ordinal)) + { + ControlCodec.Encode(empty).CopyTo(wal, 0); + if (scenario.EndsWith("missing", StringComparison.Ordinal)) wal.AsSpan(4096, 4096).Clear(); + database = PageFixture.Database(PageFixture.FinalPages); + } + else if (scenario == "recover-active-empty") + { + wal = ControlCodecTests.Header(empty with { Sequence = 5, Mode = ControlMode.Active }, empty with { Sequence = 4 }); + database = PageFixture.Database(PageFixture.FinalPages); + } + else if (scenario == "recover-active-missing") wal.AsSpan(4096, 4096).Clear(); + else if (scenario == "recover-active-retired-stale") + { + ControlCodec.Encode(ControlCodecTests.Record(3) with { Floor = ControlCodec.DataStart + 2L * ControlCodec.FrameSize }).CopyTo(wal, 0); + database = PageFixture.Database(PageFixture.RetiredPages); + } + return (wal, database, corruptOffset); + } + + // Independent oracle for this bounded fixture: hash the two slots, select the largest + // encoded sequence, and reject only absent controls or an explicitly injected active error. + private static bool ExpectedFailure(byte[] wal, int? corruptOffset) + { + byte[]? selected = null; + ulong sequence = 0; + for (int slot = 0; slot < 2; slot++) + { + byte[] bytes = wal.AsSpan(slot * ControlCodec.SlotSize, ControlCodec.SlotSize).ToArray(); + if (!SHA256.HashData(bytes.AsSpan(0, 4064)).AsSpan().SequenceEqual(bytes.AsSpan(4064))) continue; + ulong candidate = BinaryPrimitives.ReadUInt64LittleEndian(bytes.AsSpan(48)); + if (candidate >= sequence) { selected = bytes; sequence = candidate; } + } + if (selected is null) return true; + bool active = BinaryPrimitives.ReadUInt32LittleEndian(selected.AsSpan(72)) == 1; + ulong floor = BinaryPrimitives.ReadUInt64LittleEndian(selected.AsSpan(80)); + return active && corruptOffset is int offset && floor <= (ulong)offset; + } +} diff --git a/tests/CSharpDB.WalPrototype.Tests/ControlRecord.cs b/tests/CSharpDB.WalPrototype.Tests/ControlRecord.cs new file mode 100644 index 00000000..c2bbb43c --- /dev/null +++ b/tests/CSharpDB.WalPrototype.Tests/ControlRecord.cs @@ -0,0 +1,181 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; + +namespace CSharpDB.WalPrototype.Tests; + +internal enum ControlMode : uint { Active = 1, Empty = 2 } + +// Opaque 128-bit identity, encoded as two little-endian words (not Guid byte order). +internal readonly record struct FileIdentity(ulong Low, ulong High) +{ + public static FileIdentity Create() + { + Span bytes = stackalloc byte[16]; + FileIdentity value; + do + { + RandomNumberGenerator.Fill(bytes); + value = new(BinaryPrimitives.ReadUInt64LittleEndian(bytes), + BinaryPrimitives.ReadUInt64LittleEndian(bytes[8..])); + } while (value == default); + return value; + } +} + +internal readonly record struct GenerationSalt(uint First, uint Second) +{ + public static GenerationSalt Create(GenerationSalt previous) + { + Span bytes = stackalloc byte[8]; + GenerationSalt value; + do + { + RandomNumberGenerator.Fill(bytes); + value = new(BinaryPrimitives.ReadUInt32LittleEndian(bytes), + BinaryPrimitives.ReadUInt32LittleEndian(bytes[4..])); + } while (value.First == 0 || value.Second == 0 || value == previous); + return value; + } +} + +internal sealed record ControlRecord(FileIdentity Identity, ulong Sequence, ulong Epoch, + GenerationSalt Salt, ControlMode Mode, long Floor, uint PageCount) +{ + // Opt-in prototype feature. Null retains the original floor-only experiment. + public long? CommittedTail { get; init; } + public bool SameBoundary(ControlRecord other) => this with { Sequence = other.Sequence } == other; +} + +internal sealed record ControlSelection(ControlRecord Current, int CurrentSlot, + ControlRecord? First, ControlRecord? Second) +{ + public bool Mirrored => First is not null && Second is not null && First.SameBoundary(Second); +} + +internal static class ControlCodec +{ + public const int SlotSize = 4096; + public const int DataStart = 2 * SlotSize; + public const int PageSize = 4096; + public const int FrameSize = 4120; + public const int DigestOffset = SlotSize - 32; + public const uint CommittedTailFeature = 1; + + public static byte[] Encode(ControlRecord record) + { + Validate(record, long.MaxValue); + byte[] bytes = new byte[SlotSize]; + "CWA2"u8.CopyTo(bytes); + Write32(bytes, 4, 2); + Write32(bytes, 8, record.CommittedTail.HasValue ? CommittedTailFeature : 0); + Write32(bytes, 12, SlotSize); + Write32(bytes, 16, PageSize); + Write32(bytes, 20, FrameSize); + Write64(bytes, 24, DataStart); + Write64(bytes, 32, record.Identity.Low); + Write64(bytes, 40, record.Identity.High); + Write64(bytes, 48, record.Sequence); + Write64(bytes, 56, record.Epoch); + Write32(bytes, 64, record.Salt.First); + Write32(bytes, 68, record.Salt.Second); + Write32(bytes, 72, (uint)record.Mode); + Write64(bytes, 80, checked((ulong)record.Floor)); + Write32(bytes, 88, record.PageCount); + if (record.CommittedTail is long tail) Write64(bytes, 96, checked((ulong)tail)); + SHA256.HashData(bytes.AsSpan(0, DigestOffset), bytes.AsSpan(DigestOffset)); + return bytes; + } + + // Null means unreadable/torn. Checksummed but unsupported/malformed data is fatal, + // including in the older slot; callers must never catch it and choose a stale floor. + public static ControlRecord? Decode(ReadOnlySpan bytes, long physicalLength) + { + if (bytes.Length != SlotSize) + return null; + Span digest = stackalloc byte[32]; + SHA256.HashData(bytes[..DigestOffset], digest); + if (!CryptographicOperations.FixedTimeEquals(digest, bytes[DigestOffset..])) + return null; + Require(bytes[..4].SequenceEqual("CWA2"u8), "Unsupported magic."); + Require(Read32(bytes, 4) == 2, "Unsupported version."); + uint features = Read32(bytes, 8); + Require((features & ~CommittedTailFeature) == 0, "Unsupported feature flags."); + Require(Read32(bytes, 12) == SlotSize && Read32(bytes, 16) == PageSize && + Read32(bytes, 20) == FrameSize && Read64(bytes, 24) == DataStart, "Unsupported layout."); + Require(bytes.Slice(76, 4).IndexOfAnyExcept((byte)0) < 0 && + bytes[92..96].IndexOfAnyExcept((byte)0) < 0 && + bytes[104..DigestOffset].IndexOfAnyExcept((byte)0) < 0, "Nonzero reserved bytes."); + ulong tail = Read64(bytes, 96); + bool hasTail = (features & CommittedTailFeature) != 0; + Require(hasTail ? tail <= long.MaxValue : tail == 0, "Invalid committed-tail field."); + ulong floor = Read64(bytes, 80); + Require(floor <= long.MaxValue, "Replay floor overflows file offsets."); + var record = new ControlRecord(new(Read64(bytes, 32), Read64(bytes, 40)), + Read64(bytes, 48), Read64(bytes, 56), new(Read32(bytes, 64), Read32(bytes, 68)), + (ControlMode)Read32(bytes, 72), (long)floor, Read32(bytes, 88)) + { CommittedTail = hasTail ? (long)tail : null }; + Validate(record, physicalLength); + return record; + } + + public static ControlSelection Select(ReadOnlySpan header, long physicalLength) + { + var first = Decode(header[..Math.Min(header.Length, SlotSize)], physicalLength); + var second = header.Length < SlotSize ? null : + Decode(header.Slice(SlotSize, Math.Min(header.Length - SlotSize, SlotSize)), physicalLength); + Require(first is not null || second is not null, "No readable control record."); + if (first is not null && second is not null) + { + Require(first.Identity == second.Identity, "Conflicting file identities."); + Require(first.CommittedTail.HasValue == second.CommittedTail.HasValue, "Conflicting feature flags."); + Require(first.Sequence != second.Sequence || first == second, "Conflicting equal sequences."); + var older = first.Sequence <= second.Sequence ? first : second; + var newer = first.Sequence <= second.Sequence ? second : first; + Require(newer.Epoch >= older.Epoch, "Generation moved backwards."); + if (newer.Epoch == older.Epoch) + { + Require(newer.Salt == older.Salt, "Conflicting salts in one generation."); + Require(older.Mode != ControlMode.Active || newer.Mode == ControlMode.Active, + "EMPTY requires a new generation."); + if (older.Mode == ControlMode.Active) + { + Require(newer.Floor >= older.Floor, "Replay floor moved backwards."); + if (older.CommittedTail is long oldTail) + Require(newer.CommittedTail >= oldTail, "Committed tail moved backwards."); + } + } + else + Require(newer.Salt != older.Salt, "New generation reused salts."); + } + bool chooseFirst = second is null || (first is not null && first.Sequence >= second.Sequence); + return new(chooseFirst ? first! : second!, chooseFirst ? 0 : 1, first, second); + } + + public static void Validate(ControlRecord record, long physicalLength) + { + Require(record.Identity != default, "Missing file identity."); + Require(record.Sequence != 0 && record.Epoch != 0, "Zero sequence or epoch."); + Require(record.Salt.First != 0 && record.Salt.Second != 0, "Missing generation salts."); + Require(record.Mode is ControlMode.Active or ControlMode.Empty, "Unknown mode."); + Require(physicalLength >= DataStart, "Truncated control area."); + Require(record.Floor >= DataStart && record.Floor <= physicalLength && + (record.Floor - DataStart) % FrameSize == 0, "Invalid replay floor."); + Require(record.Mode != ControlMode.Empty || record.Floor == DataStart, "EMPTY floor must be H."); + if (record.CommittedTail is long tail) + { + Require(tail >= record.Floor && tail <= physicalLength && + (tail - DataStart) % FrameSize == 0, "Invalid committed tail."); + Require(record.Mode != ControlMode.Empty || tail == DataStart, "EMPTY committed tail must be H."); + } + } + + private static void Require(bool condition, string message) + { + if (!condition) throw new InvalidDataException(message); + } + + private static uint Read32(ReadOnlySpan bytes, int offset) => BinaryPrimitives.ReadUInt32LittleEndian(bytes[offset..]); + private static ulong Read64(ReadOnlySpan bytes, int offset) => BinaryPrimitives.ReadUInt64LittleEndian(bytes[offset..]); + private static void Write32(Span bytes, int offset, uint value) => BinaryPrimitives.WriteUInt32LittleEndian(bytes[offset..], value); + private static void Write64(Span bytes, int offset, ulong value) => BinaryPrimitives.WriteUInt64LittleEndian(bytes[offset..], value); +} diff --git a/tests/CSharpDB.WalPrototype.Tests/ExperimentalWalFile.cs b/tests/CSharpDB.WalPrototype.Tests/ExperimentalWalFile.cs new file mode 100644 index 00000000..ddce51e7 --- /dev/null +++ b/tests/CSharpDB.WalPrototype.Tests/ExperimentalWalFile.cs @@ -0,0 +1,58 @@ +using System.Buffers.Binary; + +namespace CSharpDB.WalPrototype.Tests; + +internal sealed class ExperimentalWalFile : IControlFile, IDisposable +{ + private readonly FileStream _stream; + private readonly Action? _point; + private string _lastWrite = "recovery"; + public long Length => _stream.Length; + + public ExperimentalWalFile(string path, Action? point) + { + _stream = new(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None, 1, FileOptions.RandomAccess); + _point = point; + } + + public byte[] ReadHeader() => ReadAt(0, (int)Math.Min(Length, ControlCodec.DataStart)); + + public byte[] ReadAt(long offset, int count) + { + byte[] bytes = new byte[count]; + _stream.Position = offset; + _stream.ReadExactly(bytes); + return bytes; + } + + public void WriteAt(long offset, ReadOnlySpan bytes, string operation) + { + _stream.Position = offset; + _stream.Write(bytes); + _lastWrite = operation; + _point?.Invoke(operation + "-written"); + } + + public void WriteSlot(int slot, byte[] bytes) + { + if (slot is < 0 or > 1 || bytes.Length != ControlCodec.SlotSize) throw new ArgumentOutOfRangeException(nameof(slot)); + var mode = (ControlMode)BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(72)); + WriteAt(slot * ControlCodec.SlotSize, bytes, $"control-{mode.ToString().ToLowerInvariant()}-{slot}"); + } + + public void Flush() + { + _stream.Flush(true); + _point?.Invoke(_lastWrite + "-flushed"); + } + + public void Trim(long length) + { + _stream.SetLength(length); + _lastWrite = "truncate"; + _point?.Invoke("truncate-written"); + } + + public void Truncate() => Trim(ControlCodec.DataStart); + public void Dispose() => _stream.Dispose(); +} diff --git a/tests/CSharpDB.WalPrototype.Tests/ExperimentalWalStore.cs b/tests/CSharpDB.WalPrototype.Tests/ExperimentalWalStore.cs new file mode 100644 index 00000000..c8bb7764 --- /dev/null +++ b/tests/CSharpDB.WalPrototype.Tests/ExperimentalWalStore.cs @@ -0,0 +1,333 @@ +namespace CSharpDB.WalPrototype.Tests; + +internal sealed class PrototypeBusyException(string message) : InvalidOperationException(message); + +// Fresh raw page files only. This joins the control protocol to real frame encoding, +// transactional replay, physical offsets and a conservative single-owner page store. +// It is not the production Pager, SQL engine, snapshot format or group-commit path. +internal sealed class ExperimentalWalStore : IDisposable +{ + public const long DefaultBudget = 64L * 1024 * 1024; + private readonly object _gate = new(); + private readonly ExperimentalWalFile _wal; + private readonly FileStream _database; + private readonly ControlProtocol _control; + private readonly Action? _point; + private readonly long _budget; + private Dictionary _pages; + private uint _pageCount; + private long _publishedTail; + private long _reserved; + private int _readers; + private bool _failed, _disposed; + private CheckpointCapture? _capture; + + public long PublishedTail { get { lock (_gate) return _publishedTail; } } + public long PhysicalLength { get { lock (_gate) return _wal.Length; } } + public long ReservedBytes { get { lock (_gate) return _reserved; } } + public long RetiredBytes { get { lock (_gate) return _control.Current.Floor - ControlCodec.DataStart; } } + public ulong Epoch { get { lock (_gate) return _control.Current.Epoch; } } + public GenerationSalt Salt { get { lock (_gate) return _control.Current.Salt; } } + public uint PageCount { get { lock (_gate) return _pageCount; } } + + public static ExperimentalWalStore Create(string directory, IReadOnlyList initialPages, + long budget = DefaultBudget, Action? point = null, bool committedTail = false) + { + ValidateBudget(budget); + if (initialPages.Count is < 1 or > (int)WalFrameCodec.MaximumPageCount || + initialPages.Any(p => p.Length != ControlCodec.PageSize)) throw new ArgumentException("Invalid initial pages."); + string basePath = Path.Combine(directory, "database.pages"), walPath = Path.Combine(directory, "database.wal"); + if (File.Exists(basePath) || File.Exists(walPath)) throw new IOException("Fresh files are required."); + Directory.CreateDirectory(directory); + using (var database = new FileStream(basePath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + foreach (byte[] page in initialPages) database.Write(page); + database.Flush(true); + } + var record = new ControlRecord(FileIdentity.Create(), 1, 1, GenerationSalt.Create(default), + ControlMode.Active, ControlCodec.DataStart, (uint)initialPages.Count) + { CommittedTail = committedTail ? ControlCodec.DataStart : null }; + using (var wal = new FileStream(walPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + wal.Write(ControlCodec.Encode(record)); + wal.Flush(true); + wal.Write(ControlCodec.Encode(record with { Sequence = 2 })); + wal.Flush(true); + } + return Open(directory, budget, point); + } + + public static ExperimentalWalStore Open(string directory, long budget = DefaultBudget, Action? point = null) => + new(directory, budget, point); + + private ExperimentalWalStore(string directory, long budget, Action? point) + { + ValidateBudget(budget); + _budget = budget; _point = point; + _wal = new(Path.Combine(directory, "database.wal"), point); + FileStream? database = null; + try + { + if (_wal.Length > budget) throw new InvalidDataException("Physical WAL exceeds configured prototype budget."); + _control = new(_wal); + uint basePages = _control.Current.PageCount; + if (basePages is 0 or > WalFrameCodec.MaximumPageCount) throw new InvalidDataException("Invalid checkpoint page count."); + database = new(Path.Combine(directory, "database.pages"), FileMode.Open, FileAccess.ReadWrite, + FileShare.None, 1, FileOptions.RandomAccess); + if (database.Length < checked((long)basePages * ControlCodec.PageSize)) + throw new InvalidDataException("Truncated checkpoint database."); + // Validate the entire declared committed region before repairing or truncating. + // With no committed-tail feature, all physical bytes must still be examined. + var recovered = WalFrameCodec.Recover(_wal, _control.Current); + _control.PrepareForWrites(); + if (_wal.Length > recovered.PublishedTail) + { + _wal.Trim(recovered.PublishedTail); + _wal.Flush(); + } + _database = database; + _pages = recovered.Pages; _pageCount = recovered.PageCount; _publishedTail = recovered.PublishedTail; + } + catch { database?.Dispose(); _wal.Dispose(); throw; } + } + + public void Commit(IReadOnlyList writes, uint pageCount) => Commit(writes, pageCount, CancellationToken.None); + + public void Commit(IReadOnlyList writes, uint pageCount, CancellationToken cancellationToken) + { + lock (_gate) + { + RequireUsable(); + cancellationToken.ThrowIfCancellationRequested(); + if (writes.Count == 0 || pageCount < _pageCount || pageCount > WalFrameCodec.MaximumPageCount || + writes.Any(p => p.PageId >= pageCount || p.Data.Length != ControlCodec.PageSize)) + throw new ArgumentException("Invalid batch or unsupported page-count shrink."); + for (uint page = _pageCount; page < pageCount; page++) + if (!writes.Any(p => p.PageId == page)) throw new ArgumentException("Growth requires every new page image."); + long bytes = checked((long)writes.Count * ControlCodec.FrameSize); + if (bytes > _budget - ControlCodec.DataStart) throw new InvalidOperationException("Batch exceeds the post-reset WAL capacity."); + if (checked(_publishedTail + _reserved + bytes) > _budget) ResetCore(); + _reserved = bytes; + long start = _publishedTail; + bool publicationStarted = false; + try + { + Dictionary committed = []; + byte[]? lastFrame = null; + for (int i = 0; i < writes.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + long offset = checked(start + (long)i * ControlCodec.FrameSize); + byte[] frame = WalFrameCodec.Encode(writes[i], 0, _control.Current.Salt); + lastFrame = frame; + // Separate writes expose deterministic process-kill points inside a frame. + _wal.WriteAt(offset, frame.AsSpan(0, 24), $"frame-{i}-header"); + _wal.WriteAt(offset + 24, frame.AsSpan(24, 2048), $"frame-{i}-half"); + _wal.WriteAt(offset + 2072, frame.AsSpan(2072), $"frame-{i}"); + committed[writes[i].PageId] = offset; + } + cancellationToken.ThrowIfCancellationRequested(); + publicationStarted = true; + byte[] marker = WalFrameCodec.CommitHeader(lastFrame!, pageCount); + long markerOffset = checked(start + (writes.Count - 1L) * ControlCodec.FrameSize); + _wal.WriteAt(markerOffset, marker.AsSpan(0, 8), "commit-marker-half"); + _wal.WriteAt(markerOffset + 8, marker.AsSpan(8, 16), "commit-marker"); + if (_control.Current.CommittedTail.HasValue) + _control.PublishCommittedTail(checked(start + bytes)); + else + _wal.Flush(); + foreach (var entry in committed) _pages[entry.Key] = entry.Value; + _pageCount = pageCount; + _publishedTail = checked(start + bytes); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested && !publicationStarted) + { + // No commit marker was published by the cancellation checks. Restore the + // previous physical tail before allowing another batch to reuse its offsets. + try { _wal.Trim(start); _wal.Flush(); } + catch { _failed = true; throw; } + throw; + } + catch { _failed = true; throw; } + finally { _reserved = 0; } + } + } + + public byte[] Read(uint pageId) + { + lock (_gate) { RequireUsable(); return ReadCore(pageId, _pageCount, _pages, _control.Current.Salt); } + } + + public ReaderSnapshot BeginRead() + { + lock (_gate) + { + RequireUsable(); + var snapshot = new ReaderSnapshot(this, _pageCount, new(_pages), _control.Current.Salt); + _readers++; + return snapshot; + } + } + + public CheckpointCapture CaptureCheckpoint() + { + lock (_gate) + { + RequireUsable(); + if (_capture is not null) throw new PrototypeBusyException("A checkpoint is already pending."); + return _capture = new(this, _control.Current.Epoch, _publishedTail, _pageCount, new(_pages)); + } + } + + public void CopyCheckpoint(CheckpointCapture capture) + { + lock (_gate) + { + ValidateCapture(capture); + RequireNoReaders(); + if (capture.Copied) throw new InvalidOperationException("Checkpoint already copied."); + Io(() => { CopyPages(capture.Pages, capture.PageCount); capture.Copied = true; }); + } + } + + public void RetireCheckpoint(CheckpointCapture capture) + { + lock (_gate) + { + ValidateCapture(capture); + RequireNoReaders(); + if (!capture.Copied) throw new InvalidOperationException("Checkpoint must be flushed first."); + Io(() => + { + // CopyCheckpoint already flushed this exact immutable capture under the gate. + _control.Retire(capture.Floor, capture.PageCount, () => { }); + foreach (uint id in _pages.Where(p => p.Value < capture.Floor).Select(p => p.Key).ToArray()) _pages.Remove(id); + _capture = null; + _point?.Invoke("floor-published"); + }); + } + } + + public void Reset() + { + lock (_gate) { RequireUsable(); ResetCore(); } + } + + private void ResetCore() + { + RequireNoReaders(); + if (_capture is not null || _reserved != 0) throw new PrototypeBusyException("Checkpoint or append is pending."); + Io(() => + { + _control.Reset(_pageCount, true, () => CopyPages(_pages, _pageCount)); + _pages.Clear(); + _publishedTail = ControlCodec.DataStart; + _point?.Invoke("reset-complete"); + }); + } + + private void CopyPages(Dictionary pages, uint count) + { + foreach (var pair in pages.OrderBy(p => p.Key)) + { + byte[] data = ReadCore(pair.Key, count, pages, _control.Current.Salt); + _database.Position = checked((long)pair.Key * ControlCodec.PageSize); + _database.Write(data); + _point?.Invoke($"base-page-{pair.Key}-written"); + } + // The prototype supports growth only, so an interrupted copy never removes base pages. + if (_database.Length < checked((long)count * ControlCodec.PageSize)) + throw new InvalidDataException("Checkpoint lacks a required page."); + _database.Flush(true); + _point?.Invoke("base-flushed"); + } + + private byte[] ReadCore(uint pageId, uint count, Dictionary pages, GenerationSalt salt) + { + if (pageId >= count) throw new ArgumentOutOfRangeException(nameof(pageId)); + if (pages.TryGetValue(pageId, out long offset)) + { + byte[] frame = _wal.ReadAt(offset, ControlCodec.FrameSize); + if (WalFrameCodec.Decode(frame, salt).PageId != pageId) throw new InvalidDataException("Frame map page mismatch."); + return frame[WalFrameCodec.HeaderSize..]; + } + byte[] result = new byte[ControlCodec.PageSize]; + _database.Position = checked((long)pageId * ControlCodec.PageSize); + _database.ReadExactly(result); + return result; + } + + private void ValidateCapture(CheckpointCapture capture) + { + RequireUsable(); + if (!ReferenceEquals(capture, _capture) || !ReferenceEquals(capture.Owner, this) || capture.Epoch != _control.Current.Epoch || + capture.Floor < _control.Current.Floor || capture.Floor > _publishedTail) + throw new InvalidOperationException("Stale or foreign checkpoint capture."); + } + + private void RequireNoReaders() + { + if (_readers != 0) throw new PrototypeBusyException("Reader snapshots must drain before checkpoint copy or reset."); + } + + private void RequireUsable() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_failed) throw new InvalidOperationException("Reopen after failed storage I/O."); + } + + private void Io(Action action) + { + try { action(); } + catch { _failed = true; throw; } + finally { _reserved = 0; } + } + + private static void ValidateBudget(long budget) + { + if (budget < ControlCodec.DataStart + ControlCodec.FrameSize) throw new ArgumentOutOfRangeException(nameof(budget)); + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) return; + RequireNoReaders(); + _disposed = true; + _database.Dispose(); _wal.Dispose(); + } + } + + internal sealed class CheckpointCapture(ExperimentalWalStore owner, ulong epoch, long floor, uint count, Dictionary pages) + { + internal ExperimentalWalStore Owner { get; } = owner; + internal ulong Epoch { get; } = epoch; + internal long Floor { get; } = floor; + internal uint PageCount { get; } = count; + internal Dictionary Pages { get; } = pages; + internal bool Copied { get; set; } + } + + internal sealed class ReaderSnapshot(ExperimentalWalStore owner, uint count, Dictionary pages, GenerationSalt salt) : IDisposable + { + private bool _closed; + public byte[] Read(uint id) + { + lock (owner._gate) + { + ObjectDisposedException.ThrowIf(_closed, this); + owner.RequireUsable(); + return owner.ReadCore(id, count, pages, salt); + } + } + public void Dispose() + { + lock (owner._gate) + { + if (_closed) return; + _closed = true; owner._readers--; + } + } + } +} diff --git a/tests/CSharpDB.WalPrototype.Tests/FaultHarness.cs b/tests/CSharpDB.WalPrototype.Tests/FaultHarness.cs new file mode 100644 index 00000000..4a6baa99 --- /dev/null +++ b/tests/CSharpDB.WalPrototype.Tests/FaultHarness.cs @@ -0,0 +1,239 @@ +using System.Buffers.Binary; +using System.Text.Json; + +namespace CSharpDB.WalPrototype.Tests; + +internal sealed record CrashImage(string Point, byte[] Wal, byte[] Database); + +internal static class Scratch +{ + // Keep every invocation separate, including failed tests. Never reuse or delete evidence. + public static string Create(string name) + { + string root = Environment.GetEnvironmentVariable("CSHARPDB_WAL_PROTOTYPE_EVIDENCE") ?? + Path.Combine(Path.GetTempPath(), "CSharpDB.WalPrototype"); + string path = Path.Combine(root, name + "-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + public static void WriteJson(string path, object value) + { + using var stream = new FileStream(path, FileMode.CreateNew); + JsonSerializer.Serialize(stream, value, new JsonSerializerOptions { WriteIndented = true }); + } +} + +// This fixture has whole, individually committed page records. It intentionally does NOT +// implement CSharpDB's frame checksum, commit markers, partial transactions or replay parser. +internal static class PageFixture +{ + public static readonly int[] InitialPages = [10, 20, 30]; + public static readonly int[] RetiredPages = [11, 21, 30]; + public static readonly int[] FinalPages = [12, 21, 30]; + + public static byte[] Database(int[] values) + { + byte[] bytes = new byte[values.Length * ControlCodec.PageSize]; + for (int i = 0; i < values.Length; i++) + bytes.AsSpan(i * ControlCodec.PageSize, ControlCodec.PageSize).Fill(checked((byte)values[i])); + return bytes; + } + + public static byte[] Frame(int page, int value, GenerationSalt salt) + { + byte[] bytes = new byte[ControlCodec.FrameSize]; + BinaryPrimitives.WriteInt32LittleEndian(bytes, page); + BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(4), value); + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(8), salt.First); + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(12), salt.Second); + "FIXTURE!"u8.CopyTo(bytes.AsSpan(16)); + bytes.AsSpan(24).Fill(checked((byte)value)); + return bytes; + } + + public static byte[] Wal() => [.. ControlCodecTests.Header(ControlCodecTests.Record(1), + ControlCodecTests.Record(2)), .. Frame(0, 11, new(3, 4)), .. Frame(1, 21, new(3, 4)), + .. Frame(0, 12, new(3, 4))]; + + public static byte[] Recover(byte[] wal, byte[] database) + { + var record = ControlCodec.Select(wal.AsSpan(0, Math.Min(wal.Length, ControlCodec.DataStart)), wal.Length).Current; + if (record.PageCount != 3 || database.Length != 3 * ControlCodec.PageSize) + throw new InvalidDataException("Invalid fixture database extent."); + byte[] result = (byte[])database.Clone(); + if (record.Mode == ControlMode.Empty) return result; + for (long offset = record.Floor; offset < wal.Length; offset += ControlCodec.FrameSize) + { + if (wal.Length - offset < ControlCodec.FrameSize) + throw new InvalidDataException("Partial fixture frame."); + var frame = wal.AsSpan((int)offset, ControlCodec.FrameSize); + int page = BinaryPrimitives.ReadInt32LittleEndian(frame); + int value = BinaryPrimitives.ReadInt32LittleEndian(frame[4..]); + if (page is < 0 or >= 3 || value is < 0 or > 255 || + !frame.SequenceEqual(Frame(page, value, record.Salt))) + throw new InvalidDataException("Invalid active fixture frame."); + frame[24..].CopyTo(result.AsSpan(page * ControlCodec.PageSize, ControlCodec.PageSize)); + } + return result; + } +} + +// Deterministic persistence model over actual files. We first exercise real writes and +// Flush(true), then materialize each allowed interrupted image in a distinct file and reopen +// it through the real codec. These are injected images, not a claim about power-loss behavior. +internal sealed class TraceFile : IControlFile, IDisposable +{ + private readonly FileControlStore _walFile; + private readonly FileStream _databaseFile; + private byte[] _wal, _database, _durableWal, _durableDatabase; + private int? _pendingSlot; + private bool _pendingTruncate; + private readonly int _throwAt; + private readonly bool _throwAfter; + public List Images { get; } = []; + public List Operations { get; } = []; + public Action? OnOperation { get; set; } + public string DirectoryPath { get; } + public long Length => _walFile.Length; + public byte[] ReadHeader() => _walFile.ReadHeader(); + + public TraceFile(string path, byte[] wal, byte[] database, int throwAt = 0, bool throwAfter = false) + { + DirectoryPath = path; + File.WriteAllBytes(Path.Combine(path, "working.wal"), wal); + File.WriteAllBytes(Path.Combine(path, "working.base"), database); + _walFile = new(Path.Combine(path, "working.wal")); + _databaseFile = new(Path.Combine(path, "working.base"), FileMode.Open, FileAccess.ReadWrite, + FileShare.None, bufferSize: 1, FileOptions.RandomAccess); + _walFile.Flush(); + _databaseFile.Flush(true); + _wal = (byte[])wal.Clone(); _durableWal = (byte[])wal.Clone(); + _database = (byte[])database.Clone(); _durableDatabase = (byte[])database.Clone(); + _throwAt = throwAt; _throwAfter = throwAfter; + Capture("initial", _durableWal, _durableDatabase); + } + + public void CopyAndFlushBase(int[] values) + { + Before("write-base"); + _database = PageFixture.Database(values); + _databaseFile.Position = 0; + _databaseFile.Write(_database); + BaseCuts("during-base-write"); + After(); + Before("flush-base"); + BaseCuts("during-base-flush"); + _databaseFile.Flush(true); + _durableDatabase = (byte[])_database.Clone(); + Capture("after-base-flush", _durableWal, _durableDatabase); + After(); + } + + public void WriteSlot(int slot, byte[] bytes) + { + Before($"write-slot-{slot}"); + if (_pendingSlot is not null || _pendingTruncate) + throw new InvalidOperationException("Prototype must flush each mutation separately."); + _walFile.WriteSlot(slot, bytes); + bytes.CopyTo(_wal, slot * ControlCodec.SlotSize); + _pendingSlot = slot; + WalCuts($"during-slot-{slot}-write"); + After(); + } + + public void Flush() + { + Before("flush-wal"); + WalCuts("during-wal-flush"); + _walFile.Flush(); + _durableWal = (byte[])_wal.Clone(); + _pendingSlot = null; _pendingTruncate = false; + Capture("after-wal-flush", _durableWal, _durableDatabase); + After(); + } + + public void Truncate() + { + Before("truncate"); + _walFile.Truncate(); + Array.Resize(ref _wal, ControlCodec.DataStart); + _pendingTruncate = true; + WalCuts("during-truncate"); + After(); + } + + private void WalCuts(string point) + { + if (_pendingSlot is int slot) + { + int start = slot * ControlCodec.SlotSize; + foreach (int cut in new[] { 0, 1, 4, 48, 64, 80, 92, 512, 2048, 4064, 4095, 4096 }) + { + byte[] prefix = (byte[])_durableWal.Clone(), suffix = (byte[])_durableWal.Clone(); + _wal.AsSpan(start, cut).CopyTo(prefix.AsSpan(start)); + _wal.AsSpan(start + cut, ControlCodec.SlotSize - cut).CopyTo(suffix.AsSpan(start + cut)); + Capture($"{point}-prefix-{cut}", prefix, _durableDatabase); + Capture($"{point}-suffix-{cut}", suffix, _durableDatabase); + } + // Noncontiguous persistence complements all byte-boundary prefix/suffix codec tests. + foreach (int mask in new[] { 0x55, 0xAA, 0x81, 0x7E }) + { + byte[] bytes = (byte[])_durableWal.Clone(); + for (int sector = 0; sector < 8; sector++) + if ((mask & (1 << sector)) != 0) + _wal.AsSpan(start + sector * 512, 512).CopyTo(bytes.AsSpan(start + sector * 512)); + Capture($"{point}-sectors-{mask}", bytes, _durableDatabase); + } + } + else if (_pendingTruncate) + { + foreach (int length in new[] { ControlCodec.DataStart, ControlCodec.DataStart + 1, + (_durableWal.Length + ControlCodec.DataStart) / 2, _durableWal.Length - 1, _durableWal.Length } + .Where(x => x >= ControlCodec.DataStart && x <= _durableWal.Length).Distinct()) + Capture($"{point}-length-{length}", _durableWal[..length], _durableDatabase); + } + } + + private void BaseCuts(string point) + { + for (int mask = 0; mask < 8; mask++) + { + byte[] bytes = (byte[])_durableDatabase.Clone(); + for (int page = 0; page < 3; page++) + if ((mask & (1 << page)) != 0) + _database.AsSpan(page * ControlCodec.PageSize, ControlCodec.PageSize) + .CopyTo(bytes.AsSpan(page * ControlCodec.PageSize)); + Capture($"{point}-pages-{mask}", _durableWal, bytes); + } + for (int page = 0; page < 3; page++) + { + byte[] bytes = (byte[])_durableDatabase.Clone(); + _database.AsSpan(page * ControlCodec.PageSize, ControlCodec.PageSize / 2) + .CopyTo(bytes.AsSpan(page * ControlCodec.PageSize)); + Capture($"{point}-half-page-{page}", _durableWal, bytes); + } + } + + private void Before(string operation) + { + Operations.Add(operation); + OnOperation?.Invoke(operation); + Capture($"before-{operation}", _durableWal, _durableDatabase); + if (Operations.Count == _throwAt && !_throwAfter) throw new IOException("Injected before " + operation); + } + + private void After() + { + if (Operations.Count == _throwAt && _throwAfter) throw new IOException("Injected after " + Operations[^1]); + } + + private void Capture(string point, byte[] wal, byte[] database) => + Images.Add(new($"{Operations.Count:D2}-{point}", (byte[])wal.Clone(), (byte[])database.Clone())); + + public void Dispose() + { + _databaseFile.Dispose(); + _walFile.Dispose(); + } +} diff --git a/tests/CSharpDB.WalPrototype.Tests/WalFrameCodec.cs b/tests/CSharpDB.WalPrototype.Tests/WalFrameCodec.cs new file mode 100644 index 00000000..6f7f9688 --- /dev/null +++ b/tests/CSharpDB.WalPrototype.Tests/WalFrameCodec.cs @@ -0,0 +1,92 @@ +using System.Buffers.Binary; +using CSharpDB.Storage.Integrity; + +namespace CSharpDB.WalPrototype.Tests; + +internal sealed record PageWrite(uint PageId, byte[] Data); +internal readonly record struct FrameHeader(uint PageId, uint PageCount); +internal sealed record RecoveredWal(Dictionary Pages, uint PageCount, long PublishedTail); + +// Same 24-byte header, additive checksums and last-frame commit marker as WriteAheadLog. +// Only the enclosing control region and recovery policy differ. No production binaries run here. +internal static class WalFrameCodec +{ + public const int HeaderSize = 24; + public const uint MaximumPageCount = 65536; // Bounded experiment, not a product limit. + private static readonly AdditiveChecksumProvider Checksum = new(); + + public static byte[] Encode(PageWrite page, uint pageCount, GenerationSalt salt) + { + if (page.Data.Length != ControlCodec.PageSize) throw new ArgumentException("Expected one full page."); + byte[] bytes = new byte[ControlCodec.FrameSize]; + BinaryPrimitives.WriteUInt32LittleEndian(bytes, page.PageId); + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(4), pageCount); + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(8), salt.First); + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(12), salt.Second); + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(16), unchecked(page.PageId + pageCount + salt.First + salt.Second)); + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(20), Checksum.Compute(page.Data)); + page.Data.CopyTo(bytes, HeaderSize); + return bytes; + } + + public static FrameHeader Decode(ReadOnlySpan bytes, GenerationSalt salt) + { + if (bytes.Length != ControlCodec.FrameSize) throw new InvalidDataException("Incomplete active WAL frame."); + uint pageId = BinaryPrimitives.ReadUInt32LittleEndian(bytes); + uint count = BinaryPrimitives.ReadUInt32LittleEndian(bytes[4..]); + if (BinaryPrimitives.ReadUInt32LittleEndian(bytes[8..]) != salt.First || + BinaryPrimitives.ReadUInt32LittleEndian(bytes[12..]) != salt.Second) + throw new InvalidDataException("Active frame generation mismatch."); + if (BinaryPrimitives.ReadUInt32LittleEndian(bytes[16..]) != unchecked(pageId + count + salt.First + salt.Second) || + BinaryPrimitives.ReadUInt32LittleEndian(bytes[20..]) != Checksum.Compute(bytes[HeaderSize..])) + throw new InvalidDataException("Active frame checksum mismatch."); + return new(pageId, count); + } + + public static byte[] CommitHeader(ReadOnlySpan encodedFrame, uint pageCount) + { + if (encodedFrame.Length != ControlCodec.FrameSize || pageCount == 0) throw new ArgumentException("Invalid commit frame."); + byte[] header = encodedFrame[..HeaderSize].ToArray(); + uint pageId = BinaryPrimitives.ReadUInt32LittleEndian(header); + uint first = BinaryPrimitives.ReadUInt32LittleEndian(header.AsSpan(8)); + uint second = BinaryPrimitives.ReadUInt32LittleEndian(header.AsSpan(12)); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(4), pageCount); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(16), unchecked(pageId + pageCount + first + second)); + // Retain the checksum of the bytes already written, not a reread of caller-owned data. + return header; + } + + public static RecoveredWal Recover(ExperimentalWalFile file, ControlRecord control) + { + Dictionary pages = [], pending = []; + uint pageCount = control.PageCount; + long published = control.Floor; + if (control.Mode == ControlMode.Empty) return new(pages, pageCount, ControlCodec.DataStart); + long scanEnd = control.CommittedTail ?? file.Length; + for (long offset = control.Floor; offset < scanEnd; offset = checked(offset + ControlCodec.FrameSize)) + { + // Partial bytes or an invalid complete frame may be a damaged acknowledged commit. + // Fail without changing either file; do not infer a safe tail from physical EOF. + if (scanEnd - offset < ControlCodec.FrameSize) + throw new InvalidDataException("Ambiguous partial active WAL tail."); + var header = Decode(file.ReadAt(offset, ControlCodec.FrameSize), control.Salt); + if (header.PageId >= MaximumPageCount || header.PageCount > MaximumPageCount) + throw new InvalidDataException("Frame exceeds prototype page limit."); + pending[header.PageId] = offset; + if (header.PageCount == 0) continue; + if (header.PageCount < pageCount || pending.Keys.Any(id => id >= header.PageCount)) + throw new InvalidDataException("Invalid committed page range; shrinking is not supported by this prototype."); + for (uint id = pageCount; id < header.PageCount; id++) + if (!pending.ContainsKey(id)) throw new InvalidDataException("New committed page has no image."); + foreach (var entry in pending) pages[entry.Key] = entry.Value; + pending.Clear(); + pageCount = header.PageCount; + published = checked(offset + ControlCodec.FrameSize); + } + if (control.CommittedTail.HasValue && (pending.Count != 0 || published != scanEnd)) + throw new InvalidDataException("Declared committed boundary ends in an uncommitted batch."); + // A durable tail proves later bytes unacknowledged. The original floor-only format + // can discard only a fully validated marker-free suffix; ambiguous bytes remain fatal. + return new(pages, pageCount, published); + } +} diff --git a/tests/CSharpDB.WalPrototype.Tests/WalProcessCrashTests.cs b/tests/CSharpDB.WalPrototype.Tests/WalProcessCrashTests.cs new file mode 100644 index 00000000..141a614f --- /dev/null +++ b/tests/CSharpDB.WalPrototype.Tests/WalProcessCrashTests.cs @@ -0,0 +1,138 @@ +using System.Diagnostics; +using System.Text.Json; + +namespace CSharpDB.WalPrototype.Tests; + +public sealed class WalProcessCrashTests +{ + [Theory] + [InlineData("commit", "frame-0-header-written", "reject")] + [InlineData("commit", "frame-0-half-written", "reject")] + [InlineData("commit", "frame-0-written", "old")] + [InlineData("commit", "frame-1-header-written", "reject")] + [InlineData("commit", "frame-1-half-written", "reject")] + [InlineData("commit", "frame-1-written", "old")] + [InlineData("commit", "commit-marker-half-written", "reject")] + [InlineData("commit", "commit-marker-written", "new")] + [InlineData("commit", "commit-marker-flushed", "new")] + [InlineData("commit", "after-commit-return", "new")] + [InlineData("copy", "base-page-0-written", "old")] + [InlineData("copy", "base-page-1-written", "old")] + [InlineData("copy", "base-flushed", "old")] + [InlineData("retire", "control-active-0-written", "suffix")] + [InlineData("retire", "control-active-0-flushed", "suffix")] + [InlineData("retire", "control-active-1-written", "suffix")] + [InlineData("retire", "control-active-1-flushed", "suffix")] + [InlineData("retire", "floor-published", "suffix")] + [InlineData("reset", "base-page-0-written", "old")] + [InlineData("reset", "base-flushed", "old")] + [InlineData("reset", "control-empty-0-written", "old")] + [InlineData("reset", "control-empty-0-flushed", "old")] + [InlineData("reset", "control-empty-1-written", "old")] + [InlineData("reset", "control-empty-1-flushed", "old")] + [InlineData("reset", "truncate-written", "old")] + [InlineData("reset", "truncate-flushed", "old")] + [InlineData("reset", "control-active-0-written", "old")] + [InlineData("reset", "control-active-0-flushed", "old")] + [InlineData("reset", "control-active-1-written", "old")] + [InlineData("reset", "control-active-1-flushed", "old")] + [InlineData("reset", "reset-complete", "old")] + public async Task KilledWorker_ReopensWholeCommittedStateOrRejectsAmbiguousTail(string operation, string point, string expected) + { + string directory = Scratch.Create($"process-{operation}-{point}"); + using (var store = WalStorageTests.Create(directory)) WalStorageTests.Seed(store); + await KillAt(directory, operation, point, "crash"); + PreserveBeforeRecovery(directory, "before"); + if (expected == "reject") WalStorageTests.AssertRejectedUnchanged(directory); + else + { + using var recovered = ExperimentalWalStore.Open(directory); + WalStorageTests.AssertPages(recovered, expected == "new" ? (byte)50 : (byte)12, + expected == "new" ? (byte)60 : (byte)21, expected == "suffix" ? (byte)31 : (byte)30); + // A new commit after restart must not join any discarded uncommitted batch. + recovered.Commit([new(2, WalStorageTests.Page(32))], 3, TestContext.Current.CancellationToken); + } + if (expected != "reject") + { + using var reopened = ExperimentalWalStore.Open(directory); + WalStorageTests.AssertPages(reopened, expected == "new" ? (byte)50 : (byte)12, + expected == "new" ? (byte)60 : (byte)21, 32); + } + Scratch.WriteJson(Path.Combine(directory, "process-result.json"), new { operation, point, expected, status = "passed" }); + } + + [Theory] + [InlineData("control-empty-1-flushed")] + [InlineData("truncate-written")] + [InlineData("truncate-flushed")] + [InlineData("control-active-0-flushed")] + [InlineData("control-active-1-flushed")] + [InlineData("after-recovery-return")] + public async Task RecoveryOfInterruptedReset_CanItselfBeInterrupted(string recoveryPoint) + { + string directory = Scratch.Create("process-repeated-recovery"); + using (var store = WalStorageTests.Create(directory)) WalStorageTests.Seed(store); + await KillAt(directory, "reset", "control-empty-0-flushed", "first"); + PreserveBeforeRecovery(directory, "after-first"); + await KillAt(directory, "recover", recoveryPoint, "second"); + PreserveBeforeRecovery(directory, "after-second"); + using (var recovered = ExperimentalWalStore.Open(directory)) + { + WalStorageTests.AssertPages(recovered, 12, 21, 30); + Assert.Equal(ControlCodec.DataStart, recovered.PhysicalLength); + recovered.Commit([new(2, WalStorageTests.Page(31))], 3, TestContext.Current.CancellationToken); + } + // Both ACTIVE copies must now protect a post-recovery acknowledged commit. + for (int lost = 0; lost < 2; lost++) + { + string copy = Path.Combine(directory, "lost-slot-" + lost); + Directory.CreateDirectory(copy); + File.Copy(WalStorageTests.BasePath(directory), WalStorageTests.BasePath(copy)); + byte[] wal = File.ReadAllBytes(WalStorageTests.WalPath(directory)); + wal.AsSpan(lost * ControlCodec.SlotSize, ControlCodec.SlotSize).Clear(); + File.WriteAllBytes(WalStorageTests.WalPath(copy), wal); + using var reopened = ExperimentalWalStore.Open(copy); + WalStorageTests.AssertPages(reopened, 12, 21, 31); + } + Scratch.WriteJson(Path.Combine(directory, "process-result.json"), new + { operation = "repeated-recovery", point = recoveryPoint, expected = "old-then-new-append", status = "passed", kills = 2, slotLossCases = 2 }); + } + + internal static async Task KillAt(string directory, string operation, string point, string name) + { + string worker = Path.Combine(AppContext.BaseDirectory, "crash-worker", "CSharpDB.WalPrototype.CrashHarness.dll"); + Assert.True(File.Exists(worker), "Build the test project to produce its isolated crash worker."); + string marker = Path.Combine(directory, name + "-marker.json"); + var info = new ProcessStartInfo("dotnet") + { + UseShellExecute = false, CreateNoWindow = true, + RedirectStandardOutput = true, RedirectStandardError = true + }; + foreach (string argument in new[] { worker, operation, point, directory, marker }) info.ArgumentList.Add(argument); + using var process = Process.Start(info) ?? throw new InvalidOperationException("Worker did not start."); + Task stdout = process.StandardOutput.ReadToEndAsync(), stderr = process.StandardError.ReadToEndAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + try { await process.WaitForExitAsync(timeout.Token); } + catch (OperationCanceledException) + { + process.Kill(); // Only the exact worker created above, never unrelated processes. + await process.WaitForExitAsync(); + throw new TimeoutException("Worker did not reach " + point); + } + string output = await stdout, error = await stderr; + File.WriteAllText(Path.Combine(directory, name + "-stdout.log"), output); + File.WriteAllText(Path.Combine(directory, name + "-stderr.log"), error); + Assert.True(File.Exists(marker), $"Worker exited {process.ExitCode} before {point}: {error}"); + Assert.NotEqual(0, process.ExitCode); + using var document = JsonDocument.Parse(File.ReadAllText(marker)); + Assert.Equal(point, document.RootElement.GetProperty("point").GetString()); + Assert.Equal(process.Id, document.RootElement.GetProperty("pid").GetInt32()); + Scratch.WriteJson(Path.Combine(directory, name + "-exit.json"), new { pid = process.Id, process.ExitCode, operation, point }); + } + + internal static void PreserveBeforeRecovery(string directory, string prefix) + { + File.Copy(WalStorageTests.WalPath(directory), Path.Combine(directory, prefix + ".wal")); + File.Copy(WalStorageTests.BasePath(directory), Path.Combine(directory, prefix + ".pages")); + } +} diff --git a/tests/CSharpDB.WalPrototype.Tests/WalStorageTests.cs b/tests/CSharpDB.WalPrototype.Tests/WalStorageTests.cs new file mode 100644 index 00000000..70e68374 --- /dev/null +++ b/tests/CSharpDB.WalPrototype.Tests/WalStorageTests.cs @@ -0,0 +1,322 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; + +namespace CSharpDB.WalPrototype.Tests; + +public sealed class WalStorageTests +{ + internal static byte[] Page(byte value) => Enumerable.Repeat(value, ControlCodec.PageSize).ToArray(); + internal static ExperimentalWalStore Create(string directory, long budget = ExperimentalWalStore.DefaultBudget, + Action? point = null) => ExperimentalWalStore.Create(directory, [Page(10), Page(20), Page(30)], budget, point); + internal static void Seed(ExperimentalWalStore store) + { + store.Commit([new(0, Page(11)), new(1, Page(21))], 3, TestContext.Current.CancellationToken); + store.Commit([new(0, Page(12))], 3, TestContext.Current.CancellationToken); + } + internal static void AssertPages(ExperimentalWalStore store, params byte[] values) + { + Assert.Equal((uint)values.Length, store.PageCount); + for (uint i = 0; i < values.Length; i++) Assert.Equal(Page(values[i]), store.Read(i)); + } + internal static string WalPath(string directory) => Path.Combine(directory, "database.wal"); + internal static string BasePath(string directory) => Path.Combine(directory, "database.pages"); + + [Fact] + public void FrameBytes_MatchProductionLayoutAndIndependentChecksumVector() + { + var frame = WalFrameCodec.Encode(new(2, Page(1)), 3, new(5, 7)); + Assert.Equal("020000000300000005000000070000001100000000040404", Convert.ToHexString(frame.AsSpan(0, 24))); + Assert.Equal(new FrameHeader(2, 3), WalFrameCodec.Decode(frame, new(5, 7))); + Assert.Equal(Page(1), frame[24..]); + } + + [Fact] + public void Reopen_ReplaysWholeBatchesDuplicatePagesAndGrowth() + { + string directory = Scratch.Create("storage-batches"); + using (var store = Create(directory)) + { + Seed(store); + store.Commit([new(1, Page(22)), new(1, Page(23)), new(3, Page(40))], 4, TestContext.Current.CancellationToken); + AssertPages(store, 12, 23, 30, 40); + Assert.Equal(ControlCodec.DataStart + 6L * ControlCodec.FrameSize, store.PublishedTail); + } + using var reopened = ExperimentalWalStore.Open(directory); + AssertPages(reopened, 12, 23, 30, 40); + reopened.Reset(); + Assert.Equal(ControlCodec.DataStart, reopened.PhysicalLength); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + public void RetiredCorruptIndexedOrSupersededFrame_DoesNotHideLaterCommit(int frame) + { + string directory = Scratch.Create("storage-retired-corruption"); + long physicalTail; + using (var store = Create(directory)) + { + store.Commit([new(0, Page(11))], 3, TestContext.Current.CancellationToken); + store.Commit([new(0, Page(12))], 3, TestContext.Current.CancellationToken); + var capture = store.CaptureCheckpoint(); + store.CopyCheckpoint(capture); + store.Commit([new(1, Page(21))], 3, TestContext.Current.CancellationToken); + physicalTail = store.PublishedTail; + store.RetireCheckpoint(capture); + Assert.Equal(physicalTail, store.PublishedTail); + Assert.Equal(physicalTail, store.PhysicalLength); + Assert.Equal(2L * ControlCodec.FrameSize, store.RetiredBytes); + } + byte[] wal = File.ReadAllBytes(WalPath(directory)); + wal[ControlCodec.DataStart + frame * ControlCodec.FrameSize + 24] ^= 1; + File.WriteAllBytes(WalPath(directory), wal); + using var reopened = ExperimentalWalStore.Open(directory); + AssertPages(reopened, 12, 21, 30); + Assert.Equal(physicalTail, reopened.PublishedTail); + reopened.Commit([new(2, Page(31))], 3, TestContext.Current.CancellationToken); + Assert.Equal(physicalTail + ControlCodec.FrameSize, reopened.PublishedTail); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(23)] + [InlineData(24)] + [InlineData(25)] + [InlineData(2072)] + [InlineData(4119)] + [InlineData(4120)] + [InlineData(4121)] + [InlineData(8239)] + [InlineData(8240)] + public void InterruptedTwoFrameBatch_IsAtomicOrRejectedWithoutMutation(int bytesWritten) + { + string directory = Scratch.Create("storage-tail"); + GenerationSalt salt; + long oldTail; + using (var store = Create(directory)) { Seed(store); salt = store.Salt; oldTail = store.PublishedTail; } + byte[] tail = [.. WalFrameCodec.Encode(new(0, Page(50)), 0, salt), .. WalFrameCodec.Encode(new(1, Page(60)), 3, salt)]; + using (var file = new FileStream(WalPath(directory), FileMode.Append)) { file.Write(tail.AsSpan(0, bytesWritten)); file.Flush(true); } + if (bytesWritten != 0 && bytesWritten != 4120 && bytesWritten != 8240) + { + AssertRejectedUnchanged(directory); + return; + } + using var reopened = ExperimentalWalStore.Open(directory); + AssertPages(reopened, bytesWritten == 8240 ? (byte)50 : (byte)12, bytesWritten == 8240 ? (byte)60 : (byte)21, 30); + Assert.Equal(oldTail + (bytesWritten == 8240 ? 8240 : 0), reopened.PublishedTail); + reopened.Commit([new(2, Page(31))], 3, TestContext.Current.CancellationToken); + AssertPages(reopened, bytesWritten == 8240 ? (byte)50 : (byte)12, bytesWritten == 8240 ? (byte)60 : (byte)21, 31); + } + + [Theory] + [InlineData(0)] // page ID / header checksum + [InlineData(4)] // commit marker / header checksum + [InlineData(8)] // salt + [InlineData(16)] // header checksum + [InlineData(20)] // data checksum + [InlineData(24)] // page payload + public void ActiveCorruptionBeforeLaterCommit_FailsWithoutChangingEitherFile(int byteOffset) + { + string directory = Scratch.Create("storage-active-corruption"); + using (var store = Create(directory)) Seed(store); + byte[] wal = File.ReadAllBytes(WalPath(directory)); + wal[ControlCodec.DataStart + byteOffset] ^= 1; + File.WriteAllBytes(WalPath(directory), wal); + AssertRejectedUnchanged(directory); + } + + [Fact] + public void CleanUncommittedSuffix_IsRemovedBeforeNextCommit() + { + string directory = Scratch.Create("storage-clean-tail"); + GenerationSalt salt; + long tail; + using (var store = Create(directory)) { Seed(store); salt = store.Salt; tail = store.PublishedTail; } + using (var file = new FileStream(WalPath(directory), FileMode.Append)) + { + file.Write(WalFrameCodec.Encode(new(0, Page(50)), 0, salt)); + file.Write(WalFrameCodec.Encode(new(1, Page(60)), 0, salt)); + file.Flush(true); + } + using var reopened = ExperimentalWalStore.Open(directory); + AssertPages(reopened, 12, 21, 30); + Assert.Equal(tail, reopened.PhysicalLength); + reopened.Commit([new(2, Page(31))], 3, TestContext.Current.CancellationToken); + AssertPages(reopened, 12, 21, 31); + } + + [Fact] + public void ReaderPins_BlockCopyAndReset_AndKeepOldWalAndBaseViews() + { + string directory = Scratch.Create("storage-reader"); + using var store = Create(directory); + store.Commit([new(0, Page(11))], 3, TestContext.Current.CancellationToken); + using var reader = store.BeginRead(); + store.Commit([new(0, Page(12)), new(1, Page(21))], 3, TestContext.Current.CancellationToken); + Assert.Equal(Page(11), reader.Read(0)); + Assert.Equal(Page(20), reader.Read(1)); + var capture = store.CaptureCheckpoint(); + Assert.Throws(() => store.CopyCheckpoint(capture)); + Assert.Throws(store.Reset); + Assert.Throws(() => store.RetireCheckpoint(capture)); + reader.Dispose(); + Assert.Throws(() => store.RetireCheckpoint(capture)); + store.CopyCheckpoint(capture); + using var newer = store.BeginRead(); + Assert.Equal(Page(12), newer.Read(0)); + Assert.Throws(() => store.RetireCheckpoint(capture)); + newer.Dispose(); + store.RetireCheckpoint(capture); + store.Reset(); + AssertPages(store, 12, 21, 30); + } + + [Fact] + public async Task ConcurrentWriterAndPinnedReader_KeepSnapshotStable() + { + string directory = Scratch.Create("storage-reader-concurrent"); + using var store = Create(directory); + using var reader = store.BeginRead(); + await Task.WhenAll(Task.Run(() => + { + for (byte i = 40; i < 80; i++) store.Commit([new(0, Page(i)), new(1, Page(i))], 3, TestContext.Current.CancellationToken); + }, TestContext.Current.CancellationToken), Task.Run(() => + { + for (int i = 0; i < 120; i++) { Assert.Equal(Page(10), reader.Read(0)); Assert.Equal(Page(20), reader.Read(1)); } + }, TestContext.Current.CancellationToken)); + AssertPages(store, 79, 79, 30); + } + + [Fact] + public void BudgetCountsRetiredPhysicalBytes_AndRefusesPinnedResetBeforeAppend() + { + string directory = Scratch.Create("storage-budget"); + long budget = ControlCodec.DataStart + 3L * ControlCodec.FrameSize; + using var store = Create(directory, budget); + Seed(store); + var capture = store.CaptureCheckpoint(); + store.CopyCheckpoint(capture); store.RetireCheckpoint(capture); + Assert.Equal(budget, store.PhysicalLength); + using var reader = store.BeginRead(); + ulong epoch = store.Epoch; + Assert.Throws(() => store.Commit([new(2, Page(31))], 3, TestContext.Current.CancellationToken)); + Assert.Equal(budget, store.PhysicalLength); + Assert.Equal(0, store.ReservedBytes); + Assert.Equal(epoch, store.Epoch); + Assert.Equal(Page(30), reader.Read(2)); + reader.Dispose(); + store.Commit([new(2, Page(31))], 3, TestContext.Current.CancellationToken); + Assert.Equal(epoch + 1, store.Epoch); + Assert.Equal(ControlCodec.DataStart + ControlCodec.FrameSize, store.PhysicalLength); + AssertPages(store, 12, 21, 31); + } + + [Fact] + public void RepeatedBudgetCrossings_BoundExtentAndRecoverAfterEveryGeneration() + { + string directory = Scratch.Create("storage-budget-cycles"); + long budget = ControlCodec.DataStart + 4L * ControlCodec.FrameSize; + using (Create(directory, budget)) { } + ulong epoch = 1; + long maximum = 0; + for (byte i = 40; i < 64; i++) + { + using var store = ExperimentalWalStore.Open(directory, budget); + store.Commit([new(0, Page(i)), new(1, Page(i))], 3, TestContext.Current.CancellationToken); + maximum = Math.Max(maximum, store.PhysicalLength); + Assert.InRange(store.PhysicalLength, ControlCodec.DataStart, budget); + Assert.True(store.Epoch >= epoch); epoch = store.Epoch; + AssertPages(store, i, i, 30); + } + using var final = ExperimentalWalStore.Open(directory, budget); + AssertPages(final, 63, 63, 30); + Assert.Equal((ulong)12, epoch); + Assert.Equal(budget, maximum); + Scratch.WriteJson(Path.Combine(directory, "budget-results.json"), new { commits = 24, resets = 11, maximumWalBytes = maximum, budget }); + } + + [Fact] + public void OversizedBatchAndInvalidGrowth_LeaveStoreUsable() + { + string directory = Scratch.Create("storage-rejection"); + using var store = Create(directory, ControlCodec.DataStart + ControlCodec.FrameSize); + Assert.Throws(() => store.Commit([new(0, Page(11)), new(1, Page(21))], 3, TestContext.Current.CancellationToken)); + Assert.Throws(() => store.Commit([new(4, Page(44))], 5, TestContext.Current.CancellationToken)); + Assert.Throws(() => store.Commit([new(0, Page(11))], 2, TestContext.Current.CancellationToken)); + Assert.Equal(ControlCodec.DataStart, store.PhysicalLength); + Assert.Equal(0, store.ReservedBytes); + store.Commit([new(0, Page(11))], 3, TestContext.Current.CancellationToken); + AssertPages(store, 11, 20, 30); + } + + [Theory] + [InlineData("frame-0-header-written", true)] + [InlineData("frame-0-written", false)] + [InlineData("commit-marker-half-written", true)] + [InlineData("commit-marker-flushed", false)] + public void FailedAppend_ReleasesReservationAndRequiresReopen(string point, bool rejected) + { + string directory = Scratch.Create("storage-io-failure"); + bool armed = false; + using (var store = Create(directory, point: name => { if (armed && name == point) throw new IOException("Injected append failure."); })) + { + Seed(store); armed = true; + Assert.Throws(() => store.Commit([new(2, Page(31))], 3, TestContext.Current.CancellationToken)); + Assert.Equal(0, store.ReservedBytes); + Assert.Throws(() => store.Commit([new(2, Page(32))], 3, TestContext.Current.CancellationToken)); + } + if (rejected) AssertRejectedUnchanged(directory); + else + { + using var reopened = ExperimentalWalStore.Open(directory); + AssertPages(reopened, 12, 21, point == "commit-marker-flushed" ? (byte)31 : (byte)30); + } + } + + internal static void AssertRejectedUnchanged(string directory) + { + byte[] wal = File.ReadAllBytes(WalPath(directory)), database = File.ReadAllBytes(BasePath(directory)); + Assert.Throws(() => ExperimentalWalStore.Open(directory)); + Assert.Equal(wal, File.ReadAllBytes(WalPath(directory))); + Assert.Equal(database, File.ReadAllBytes(BasePath(directory))); + } + + [Fact] + public void CancellationAfterFirstFrame_RollsBackBeforeReusingAppendPosition() + { + string directory = Scratch.Create("storage-cancel"); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + bool armed = false; + using (var store = Create(directory, point: name => { if (armed && name == "frame-0-written") cancellation.Cancel(); })) + { + Seed(store); + long tail = store.PublishedTail; + armed = true; + Assert.Throws(() => store.Commit([new(0, Page(50)), new(1, Page(60))], 3, cancellation.Token)); + armed = false; + Assert.Equal(tail, store.PhysicalLength); + Assert.Equal(tail, store.PublishedTail); + Assert.Equal(0, store.ReservedBytes); + AssertPages(store, 12, 21, 30); + store.Commit([new(2, Page(31))], 3, TestContext.Current.CancellationToken); + } + using var reopened = ExperimentalWalStore.Open(directory); + AssertPages(reopened, 12, 21, 31); + } + + [Fact] + public void CommitMarker_UsesChecksumOfEncodedPageAfterCallerBufferChanges() + { + string directory = Scratch.Create("storage-buffer-ownership"); + byte[] callerPage = Page(11); + using (var store = Create(directory, point: name => + { if (name == "frame-0-written") callerPage.AsSpan().Fill(99); })) + { + store.Commit([new(0, callerPage)], 3, TestContext.Current.CancellationToken); + AssertPages(store, 11, 20, 30); + } + using var reopened = ExperimentalWalStore.Open(directory); + AssertPages(reopened, 11, 20, 30); + } +} diff --git a/www/changelog.html b/www/changelog.html index 3b23df07..3a4d62b2 100644 --- a/www/changelog.html +++ b/www/changelog.html @@ -75,14 +75,28 @@

Changelog

Short highlights from the published release notes. Dates use UTC publication dates; development work is listed separately below.

-
+
UnreleasedDevelopment work · not in a published package
-

Privacy & Retention, Storage Inspector, and SQL editor improvements

-

Prepared for v4.6.5, based on changes since v4.6.4. This candidate is not yet published; stable downloads remain on v4.6.4.

+

Prepared queries, bounded reads, and durable writes

+

Prepared for v4.6.6, based on changes since v4.6.5. This candidate is not yet published; stable downloads remain on v4.6.5.

+
    +
  • Queries: reuse prepared SELECT plans, bound integer primary-key ranges, and preserve reader catalog snapshots across commits and checkpoints.
  • +
  • Execution: reduce scan, LIMIT, prefetch, and INSERT allocations while preserving row and storage-page ownership.
  • +
  • Durable writes: reduce checkpoint stalls and improve WAL lifecycle handling.
  • +
  • Storage correctness: prevent checkpoint reset and compaction from invalidating concurrent write-conflict WAL reads, and protect pending durable commit publication.
  • +
  • Dependencies: update client, migration, desktop, telemetry, and test packages while keeping the OpenAPI dependency compatible with .NET 10.
  • +
  • Qualification: validate performance evidence integrity and reject incomplete comparisons.
  • +
+
+

Published releases

+
+
Latest release
+

v4.6.5 — Privacy & Retention, Storage Inspector, and SQL editor improvements

+

Published changes since v4.6.4.

  • Privacy & Retention: save rules for selected live fields, preview eligible and changed records, then apply in one transaction with run receipts.
  • Storage Inspector: open on a persisted summary and request full page and WAL analysis explicitly.
  • @@ -91,10 +105,10 @@

    Privacy & Retention, Storage Inspector, and SQL editor improvements

No database file-format revision is introduced. The initial privacy workflow requires a single directly connected, file-backed database; it changes selected fields but does not delete records or guarantee that a person is unidentifiable.

Privacy & Retention guide →

+

Full v4.6.5 release notes & downloads →

-

Published releases

-
Latest release
+
Release

v4.6.4 — SQL Search, Database Documenter, and Test Data Generator

  • Find definitions and dependencies: search saved database definitions, follow Uses and Used by relationships, and assess column changes from SQL Search and Data Modeler.
  • diff --git a/www/docs/query-execution-pipeline.html b/www/docs/query-execution-pipeline.html index d45cc208..e1b09330 100644 --- a/www/docs/query-execution-pipeline.html +++ b/www/docs/query-execution-pipeline.html @@ -217,6 +217,7 @@

    3. Query Planning

    Plan classifications are cached (up to 1024 entries) so repeated queries skip re-analysis.

    +

    Eligible prepared SELECT queries reuse integer-key plans across parameter values. Each execution owns its bound values and cursor, and schema changes invalidate live cached plans. Simple physical integer primary-key ranges can use bounded B+tree scans in both prepared and ordinary queries; residual predicates still apply. Unsupported prepared shapes use ordinary binding and planning. Snapshot readers resolve table roots and schema metadata from their captured catalog.

    Cardinality Estimation

    When ANALYZE has been run on a table, the CardinalityEstimator uses collected statistics to estimate result sizes and guide operator selection.

    @@ -403,6 +404,10 @@

    Scan Operators

    Direct row lookup by integer PK. Fastest scan path. + PrimaryKeyRangeScanOperator + Bounded scan over the table's physical integer primary key with projection pushdown. + + UniqueIndexLookupOperator Single-row lookup via unique index. diff --git a/www/docs/storage-architecture.html b/www/docs/storage-architecture.html index 5aa1552b..ec21da6c 100644 --- a/www/docs/storage-architecture.html +++ b/www/docs/storage-architecture.html @@ -224,8 +224,12 @@

    Concurrency Model

    Serializes checkpoint operations - Active reader counter - Prevents checkpoint while snapshot readers are alive + Registered reader snapshots + Protect page visibility during copying and retain WAL offsets until readers release them + + + Commit mutex and begin/writer barriers + Coordinate checkpoint finalization with write-conflict reads and commit admission Immutable WalSnapshot @@ -241,8 +245,9 @@

    Transaction Behavior

    // CommitAsync // → writes all dirty pages as WAL frames -// → WAL commits (mark final frame + flush) -// → clears dirty set, releases writer lock +// → releases the legacy writer lock after append +// → waits for durable flush and committed-state publication +// → completes commit; pending publication prevents checkpoint finalization // RollbackAsync // → truncates uncommitted WAL frames @@ -253,8 +258,9 @@

    Transaction Behavior

    Checkpoint Behavior

    • Triggered manually or automatically when WAL frame count threshold is reached
    • -
    • Skips if active readers hold snapshots
    • -
    • Applies committed WAL pages back into main DB file and resets WAL/index
    • +
    • Copies a captured committed boundary to the main DB file only when registered readers' page visibility is preserved
    • +
    • Defers finalization while registered snapshots retain WAL frames or durable commits own unpublished offsets
    • +
    • Finalization acquires the commit mutex before the begin barrier, writer barrier, and checkpoint admission; it holds them through reset or retained-tail compaction and pager state refresh, releasing in reverse order on completion, cancellation, or error

    Durability & Recovery: WriteAheadLog + WalIndex

    @@ -265,7 +271,7 @@

    WriteAheadLog

  • Append page frames for dirty pages
  • Mark commit by rewriting final frame header (dbPageCount) + checksum + flush
  • Recover by scanning WAL, validating frame salts/checksums, rebuilding committed index, truncating partial/corrupt tail
  • -
  • Checkpoint committed pages to DB file and reset WAL
  • +
  • Checkpoint committed pages to DB file, then reset the WAL or compact a retained committed suffix under pager coordination

WalIndex

@@ -360,7 +366,7 @@

Write Transaction

  • Pager.BeginTransactionAsync acquires writer lock and starts WAL transaction
  • Planner/catalog/BTree mutate page byte arrays
  • Modified pages are marked dirty in pager
  • -
  • Pager.CommitAsync: updates page 0 header → appends dirty pages to WAL → WAL commits (mark final frame + flush) → WAL index visible to future readers → writer lock released
  • +
  • Pager.CommitAsync: updates page 0 header → appends dirty pages and the commit marker to WAL → releases the legacy writer lock → waits for durable flush and committed-state publication before returning
  • Optional auto-checkpoint may run when threshold reached
  • @@ -379,7 +385,7 @@

    Read Path (Normal & Snapshot)

  • Reader acquires WalSnapshot from pager
  • Read-only snapshot pager is created
  • Page resolution uses snapshot mapping only; later commits are invisible
  • -
  • On disposal, reader count decrements, allowing future checkpoint
  • +
  • On disposal, reader registration is released, allowing deferred checkpoint copy or finalization to progress
  • Query Scans

    diff --git a/www/docs/storage-engine-reference.html b/www/docs/storage-engine-reference.html index 647ff788..477a49e0 100644 --- a/www/docs/storage-engine-reference.html +++ b/www/docs/storage-engine-reference.html @@ -580,7 +580,7 @@

    P6. Snapshot Isolation (Concur pager.ReleaseReaderSnapshot();

    P7. Manual Checkpoint

    -

    Copy all committed WAL frames to the main database file, then reset the WAL. This invalidates transient WAL-backed reads, and can also invalidate transient memory-mapped views; with PreserveOwnedPagesOnCheckpoint = true, already-owned main-file pages can remain resident in the shared cache.

    +

    Copy the captured committed WAL pages to the main database file while preserving registered readers' page visibility. Finalization defers while registered snapshots retain WAL frames or durable commits have unpublished offsets; it resets the WAL or compacts a retained committed tail. Finalization shares the write-conflict commit mutex and holds it through pager state refresh. Transient page views may be invalidated; with PreserveOwnedPagesOnCheckpoint = true, already-owned main-file pages can remain resident in the shared cache.

    await pager.CheckpointAsync();
                     

    P8. Configure Checkpoint Policy

    @@ -715,13 +715,14 @@

    W2. Write Transaction to WAL

    await wal.AppendFrameAsync(pageId: 5, pageData); // Commit: the last frame gets dbPageCount > 0, marking the commit boundary - await wal.CommitAsync(newDbPageCount: pager.PageCount); + WalCommitResult commit = await wal.CommitAsync(newDbPageCount: pager.PageCount); + await commit.WaitAsync(); // durable flush and reader visibility // Or rollback: truncates uncommitted frames await wal.RollbackAsync();

    W3. Take a Reader Snapshot

    -

    Freeze the WAL index at a point in time for a concurrent reader.

    +

    Freeze the WAL index at a point in time. This direct snapshot does not register a reader with the pager: keep checkpoint reset and compaction excluded until all reads using its offsets finish. For concurrent database reads, use ReaderSession or the pager's registered snapshot API.

    WalSnapshot snapshot = walIndex.TakeSnapshot();
                     
                     // Reader uses snapshot to resolve page lookups:
    @@ -732,10 +733,9 @@ 

    W3. Take a Reader Snapshot

    }

    W4. Checkpoint WAL to Database File

    -

    Copy all committed WAL pages to the main database file, then reset the WAL.

    +

    Use Database.CheckpointAsync() or the owning Pager.CheckpointAsync() for a live database. The direct WAL call below requires standalone, externally serialized use with no active readers, writers, or pending commits. The WAL updates its own index; do not reset the index separately.

    await wal.CheckpointAsync(device, pageCount: pager.PageCount);
    -                walIndex.Reset();
    -                // WAL is now empty; all data is in the main file
    +                // In this quiescent example, all committed data is in the main file
                     

    Slotted Page Layout

    diff --git a/www/docs/storage-engine.html b/www/docs/storage-engine.html index 79b23712..8fde5de2 100644 --- a/www/docs/storage-engine.html +++ b/www/docs/storage-engine.html @@ -322,6 +322,7 @@

    Write-Ahead Log (WAL)

    Open, Write, and Checkpoint

    +

    Use Database.CheckpointAsync() or the owning Pager.CheckpointAsync() for a live database. The direct WAL example below requires standalone, externally serialized use with no concurrent readers or writers. Wait for durable commit completion before checkpointing; the WAL updates its own index.

    var walIndex = new WalIndex();
     await using var wal = new WriteAheadLog("mydb.cdb", walIndex);
    @@ -330,11 +331,11 @@ 

    Open, Write, and Checkpoint

    // Write transaction wal.BeginTransaction(); await wal.AppendFrameAsync(pageId: 1, pageData); -await wal.CommitAsync(newDbPageCount: pager.PageCount); +WalCommitResult commit = await wal.CommitAsync(newDbPageCount: pager.PageCount); +await commit.WaitAsync(); // Checkpoint to main file -await wal.CheckpointAsync(device, pageCount: pager.PageCount); -walIndex.Reset();
    +await wal.CheckpointAsync(device, pageCount: pager.PageCount);

    Slotted Page Layout

    diff --git a/www/docs/tutorials/storage.html b/www/docs/tutorials/storage.html index ad1ceb2c..773dba39 100644 --- a/www/docs/tutorials/storage.html +++ b/www/docs/tutorials/storage.html @@ -511,11 +511,13 @@

    W2. Write Transaction to WAL

    await wal.AppendFrameAsync(pageId: 1, pageData); await wal.AppendFrameAsync(pageId: 5, pageData); -await wal.CommitAsync(newDbPageCount: pager.PageCount); +WalCommitResult commit = await wal.CommitAsync(newDbPageCount: pager.PageCount); +await commit.WaitAsync(); // durable flush and reader visibility // Or rollback: await wal.RollbackAsync();

    W3. Reader Snapshot

    +

    This low-level index snapshot does not register a reader with the pager. Keep checkpoint reset and compaction excluded until all reads using its offsets finish. For concurrent database reads, use ReaderSession or the pager's registered snapshot API shown above.

    WalSnapshot snapshot = walIndex.TakeSnapshot();
     
    @@ -526,10 +528,11 @@ 

    W3. Reader Snapshot

    W4. Checkpoint

    +

    For a live database, call Database.CheckpointAsync() or the owning Pager.CheckpointAsync(). The pager protects registered readers, coordinates finalization with write-conflict resolution, and defers finalization while durable commits have unpublished offsets. Finalization may reset the WAL or compact a retained committed tail.

    +

    The direct WAL call below is for standalone, externally serialized low-level use with no active readers, writers, or pending commits. The WAL updates its own index; do not reset the index separately.

    await wal.CheckpointAsync(device, pageCount: pager.PageCount);
    -walIndex.Reset();
    -// WAL is now empty; all data is in the main file
    +// In this quiescent example, all committed data is in the main file
    diff --git a/www/downloads.html b/www/downloads.html index 6d09f573..2047ebb6 100644 --- a/www/downloads.html +++ b/www/downloads.html @@ -91,15 +91,15 @@ -
    +

    Get CSharpDB

    Explore a database, build an application, or run a server. Choose the download that fits what you want to do.

    - Latest stable: v4.6.4 - Released - What changed & upgrade notes + Latest stable: v4.6.5 + Released + What changed & upgrade notes
    diff --git a/www/roadmap-reference.html b/www/roadmap-reference.html index 034158ab..46f21405 100644 --- a/www/roadmap-reference.html +++ b/www/roadmap-reference.html @@ -213,8 +213,8 @@

    Mid-Term

    SQL feature coverage - The bounded SQL implementation and its automated functional qualification gate are complete across the public reference, regression suite, versioned migration capability catalog, replayable EF SQL corpus, historical-database reopen coverage, fault-injected recovery, and a canonical typed parity workload plus feature-specific direct/ADO.NET/HTTP/gRPC coverage where applicable. Status remains In Progress until the release commit records two clean Windows, Linux, and macOS passes, two passing GitHub-hosted comparisons of the 18 stable master-table rows, and two passing sequential local durable-write comparisons on one idle fixed-SSD Windows machine using the same hash-verified harness and valid, stable raw evidence. The local gate must publish its success status on that exact release commit. Supplemental performance suites remain report-only or manual diagnostics. Advanced SQL work is tracked in separate rows rather than keeping completed bounded implementation slices partially complete. - In Progress + Done for the bounded SQL contract: the public reference, regression suite, versioned migration capability catalog, replayable EF SQL corpus, historical-database reopen coverage, fault-injected recovery, and canonical typed parity workload plus feature-specific direct/ADO.NET/HTTP/gRPC coverage where applicable are complete. The v4.6.5 release passed the current SQL release gate on its exact commit: one clean Windows, Linux, and macOS qualification pass and one balanced paired GitHub-hosted comparison of the 18 stable master-table rows. Local durable-write comparisons and supplemental performance suites remain diagnostics under the current release policy. Advanced SQL work is tracked in separate rows. + Done User-defined functions and commands @@ -298,8 +298,8 @@

    Mid-Term

    SQL release qualification - The automated GitHub gate blocks publishing on two clean full-suite passes for each supported operating system covering durable, in-memory, reopen/recovery configurations, historical-database upgrades, transport parity, the EF corpus, executable documentation, bounded property tests, and stable diagnostics for intentionally unsupported SQL. It also runs two balanced paired previous-release comparisons for the 18 persistent-read and in-memory master-table rows that are stable on hosted Windows runners. Disk-sensitive performance qualification is a required pre-tag local gate: two sequential balanced paired passes qualify each of the remaining ten durable SQL/collection single and batch write rows independently on one idle fixed-SSD Windows machine. Every exact row uses adjacent previous/candidate measurements, pass two reverses the starting order, and every individual measurement must retain at least 30 measured seconds and 10,000 latency samples within the bounded measurement cap. Each logical side has one predeclared attempt; evidence is not discarded, replaced, or silently retried. Both revisions use symmetrically conditioned artifacts with the same hash-recorded candidate benchmark harness; raw runs, recomputed aggregates, artifact closures, and pass reports are hash bound. After post-build quiescence, a required one-second Windows monitor covers every declared measurement. Five consecutive samples above 8% observable external process CPU, 0.5 CPU-core equivalent, or 4,194,304 observable external process I/O bytes per second contaminate the pass; named external build, test, installer, or update processes contaminate immediately. Missing monitor evidence, unavailable allowed runner-tree CPU, a coverage gap above five seconds, invalid benchmark evidence, unstable, order-sensitive, or regressed blocking assessments, and missing, unverifiable, or changed Windows Installer, Application event-log, or pending-file evidence fail closed; unavailable external-process counters remain explicit diagnostics. The expected local runtime is 3.5–4.5 hours. Stable comparisons enforce a 15% throughput limit and fail P95 only when regression exceeds both 25% and 0.05 ms; P99 remains diagnostic. Only the canonical durable-v3 local policy can publish a policy-bound status on the exact candidate commit. Its description is exactly policy=durable-v3; baseline=<40 lowercase hex>; design=<8 uppercase hex>; reports=<8 uppercase>/<8 uppercase>; older policy descriptions are rejected, and the release workflow requires the status from the configured attestor before publishing. Broader performance coverage remains in the existing scheduled report-only guardrails and manual supplemental suite diagnostics. This item closes when all automated functional and hosted-stable jobs and both local durable-write passes succeed for the release commit. - In Progress + Done for the current release gate. GitHub blocks publication until one clean SQL qualification pass succeeds on each of Windows, Linux, and macOS, covering durable and in-memory configurations, reopen and recovery, historical-database upgrades, transport parity, the EF SQL corpus, executable documentation, bounded property tests, and stable diagnostics for unsupported SQL. One balanced paired Windows comparison covers the 18 hosted-stable persistent-read and in-memory master-table rows against the previous release; three repeats cover both execution orders. Throughput regressions above 15% block, and P95 blocks when regression exceeds both 25% and 0.05 ms; P99 remains diagnostic. The optional local durable-v3 two-pass comparison of ten disk-sensitive SQL/collection write rows and its exact-commit status are diagnostic and are not required for publication. Scheduled guardrails remain report-only, and supplemental performance suites remain manual diagnostics. The exact v4.6.5 release commit passed qualification and was published. + Done Remote host consolidation diff --git a/www/roadmap.html b/www/roadmap.html index fe86bd8a..f6308c0b 100644 --- a/www/roadmap.html +++ b/www/roadmap.html @@ -293,9 +293,9 @@

    SQL Feature Coverage

    - In Progress + Done
    -

    The bounded SQL implementation and its automated functional qualification gate are complete across the public reference, regression suite, versioned migration capability catalog, replayable EF SQL corpus, historical-database reopen coverage, fault-injected recovery, and a canonical typed parity workload plus feature-specific direct/ADO.NET/HTTP/gRPC coverage where applicable. Status remains In Progress until the release commit records two clean Windows, Linux, and macOS passes, two passing GitHub-hosted comparisons of the 18 stable master-table rows, and two passing sequential local durable-write comparisons on one idle fixed-SSD Windows machine using the same hash-verified harness and valid, stable raw evidence. The local gate must publish its success status on that exact release commit. Supplemental performance suites remain report-only or manual diagnostics. Advanced SQL work is tracked separately below and does not keep the bounded implementation slices partially complete.

    +

    Done for the bounded SQL contract: the public reference, regression suite, versioned migration capability catalog, replayable EF SQL corpus, historical-database reopen coverage, fault-injected recovery, and canonical typed parity workload plus feature-specific direct/ADO.NET/HTTP/gRPC coverage where applicable are complete. The v4.6.5 release passed the current SQL release gate on its exact commit: one clean Windows, Linux, and macOS qualification pass and one balanced paired GitHub-hosted comparison of the 18 stable master-table rows. Local durable-write comparisons and supplemental performance suites remain diagnostics under the current release policy. Advanced SQL work is tracked separately below.

    @@ -391,9 +391,9 @@

    Broader ORM & Metadata Compatibility

    SQL Release Qualification

    - In Progress + Done
    -

    The automated GitHub gate blocks publishing on two clean full-suite passes for each supported operating system covering durable, in-memory, reopen/recovery configurations, historical-database upgrades, transport parity, the EF corpus, executable documentation, bounded property tests, and stable diagnostics for intentionally unsupported SQL. It also runs two balanced paired previous-release comparisons for the 18 persistent-read and in-memory master-table rows that are stable on hosted Windows runners. Disk-sensitive performance qualification is a required pre-tag local gate: two sequential balanced paired passes qualify each of the remaining ten durable SQL/collection single and batch write rows independently on one idle fixed-SSD Windows machine. Every exact row uses adjacent previous/candidate measurements, pass two reverses the starting order, and every individual measurement must retain at least 30 measured seconds and 10,000 latency samples within the bounded measurement cap. Each logical side has one predeclared attempt; evidence is not discarded, replaced, or silently retried. Both revisions use symmetrically conditioned artifacts with the same hash-recorded candidate benchmark harness; raw runs, recomputed aggregates, artifact closures, and pass reports are hash bound. After post-build quiescence, a required one-second Windows monitor covers every declared measurement. Five consecutive samples above 8% observable external process CPU, 0.5 CPU-core equivalent, or 4,194,304 observable external process I/O bytes per second contaminate the pass; named external build, test, installer, or update processes contaminate immediately. Missing monitor evidence, unavailable allowed runner-tree CPU, a coverage gap above five seconds, invalid benchmark evidence, unstable, order-sensitive, or regressed blocking assessments, and missing, unverifiable, or changed Windows Installer, Application event-log, or pending-file evidence fail closed; unavailable external-process counters remain explicit diagnostics. The expected local runtime is 3.5–4.5 hours. Stable comparisons enforce a 15% throughput limit and fail P95 only when regression exceeds both 25% and 0.05 ms; P99 remains diagnostic. Only the canonical durable-v3 local policy can publish a policy-bound status on the exact candidate commit. Its description is exactly policy=durable-v3; baseline=<40 lowercase hex>; design=<8 uppercase hex>; reports=<8 uppercase>/<8 uppercase>; older policy descriptions are rejected, and the release workflow requires the status from the configured attestor before publishing. Broader performance coverage remains in the existing scheduled report-only guardrails and manual supplemental suite diagnostics. This item closes when all automated functional and hosted-stable jobs and both local durable-write passes succeed for the release commit.

    +

    Done for the current release gate. GitHub blocks publication until one clean SQL qualification pass succeeds on each of Windows, Linux, and macOS, covering durable and in-memory configurations, reopen and recovery, historical-database upgrades, transport parity, the EF SQL corpus, executable documentation, bounded property tests, and stable diagnostics for unsupported SQL. One balanced paired Windows comparison covers the 18 hosted-stable persistent-read and in-memory master-table rows against the previous release; three repeats cover both execution orders. Throughput regressions above 15% block, and P95 blocks when regression exceeds both 25% and 0.05 ms; P99 remains diagnostic. The optional local durable-v3 two-pass comparison of ten disk-sensitive SQL/collection write rows and its exact-commit status are diagnostic and are not required for publication. Scheduled guardrails remain report-only, and supplemental performance suites remain manual diagnostics. The exact v4.6.5 release commit passed qualification and was published.