diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a82940e6..eb463b4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,20 +77,26 @@ jobs: tests/CSharpDB.Admin.Reports.Tests/CSharpDB.Admin.Reports.Tests.csproj tests/CSharpDB.Admin.Forms.Tests/CSharpDB.Admin.Forms.Tests.csproj tests/CSharpDB.Api.Tests/CSharpDB.Api.Tests.csproj + tests/CSharpDB.Benchmarks.Tests/CSharpDB.Benchmarks.Tests.csproj tests/CSharpDB.Cli.Tests/CSharpDB.Cli.Tests.csproj tests/CSharpDB.CodeModules.Tests/CSharpDB.CodeModules.Tests.csproj tests/CSharpDB.Daemon.Tests/CSharpDB.Daemon.Tests.csproj tests/CSharpDB.Data.Tests/CSharpDB.Data.Tests.csproj tests/CSharpDB.DataGeneration.Tests/CSharpDB.DataGeneration.Tests.csproj + tests/CSharpDB.DataPrivacy.Tests/CSharpDB.DataPrivacy.Tests.csproj tests/CSharpDB.DevOps.Tests/CSharpDB.DevOps.Tests.csproj tests/CSharpDB.EntityFrameworkCore.Tests/CSharpDB.EntityFrameworkCore.Tests.csproj tests/CSharpDB.EntityFrameworkCore.Tools.Tests/CSharpDB.EntityFrameworkCore.Tools.Tests.csproj + tests/CSharpDB.Mcp.Tests/CSharpDB.Mcp.Tests.csproj tests/CSharpDB.Migration.Access.Tests/CSharpDB.Migration.Access.Tests.csproj tests/CSharpDB.Migration.Compatibility.Tests/CSharpDB.Migration.Compatibility.Tests.csproj tests/CSharpDB.Migration.DualRun.Tests/CSharpDB.Migration.DualRun.Tests.csproj tests/CSharpDB.Migration.Files.Tests/CSharpDB.Migration.Files.Tests.csproj + tests/CSharpDB.Migration.LiteDb.Tests/CSharpDB.Migration.LiteDb.Tests.csproj tests/CSharpDB.Migration.MySql.Tests/CSharpDB.Migration.MySql.Tests.csproj + tests/CSharpDB.Migration.Retained.Tests/CSharpDB.Migration.Retained.Tests.csproj tests/CSharpDB.Migration.SqlServer.Tests/CSharpDB.Migration.SqlServer.Tests.csproj + tests/CSharpDB.Migration.Sqlite.Tests/CSharpDB.Migration.Sqlite.Tests.csproj tests/CSharpDB.Migration.Tests/CSharpDB.Migration.Tests.csproj tests/CSharpDB.Observability.Tests/CSharpDB.Observability.Tests.csproj tests/CSharpDB.Pipelines.Tests/CSharpDB.Pipelines.Tests.csproj @@ -251,7 +257,29 @@ jobs: run: sudo apt-get update && sudo apt-get install -y clang zlib1g-dev - name: Publish NativeAOT library - run: dotnet publish src/CSharpDB.Native/CSharpDB.Native.csproj -c Release -r ${{ matrix.rid }} + run: dotnet publish src/CSharpDB.Native/CSharpDB.Native.csproj -c Release -r ${{ matrix.rid }} -p:ILLinkTreatWarningsAsErrors=true -p:IlcTreatWarningsAsErrors=true + + - name: Use private link-free temporary directory for native archive smoke (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + temp_directory="$(mktemp -d /private/tmp/csharpdb-native-archive.XXXXXX)" + chmod 700 "${temp_directory}" + echo "TMPDIR=${temp_directory}" >> "${GITHUB_ENV}" + + - name: Publish native archive SQL smoke + run: dotnet publish tests/CSharpDB.Native.ArchiveSmoke/CSharpDB.Native.ArchiveSmoke.csproj -c Release -r ${{ matrix.rid }} --self-contained true -p:PublishAot=true -o artifacts/native-archive-smoke/${{ matrix.rid }} + + - name: Run published native archive SQL smoke + shell: pwsh + run: | + $executableName = if ($IsWindows) { 'CSharpDB.Native.ArchiveSmoke.exe' } else { 'CSharpDB.Native.ArchiveSmoke' } + $executable = Join-Path 'artifacts/native-archive-smoke/${{ matrix.rid }}' $executableName + $library = [IO.Path]::GetFullPath('src/CSharpDB.Native/bin/Release/net10.0/${{ matrix.rid }}/publish/${{ matrix.lib }}') + if (-not (Test-Path -LiteralPath $executable -PathType Leaf)) { throw "Archive smoke executable missing: $executable" } + if (-not (Test-Path -LiteralPath $library -PathType Leaf)) { throw "Native library missing: $library" } + & $executable $library + if ($LASTEXITCODE -ne 0) { throw "Native archive SQL smoke failed with exit code $LASTEXITCODE." } - name: Publish observability trim and NativeAOT smoke run: dotnet publish tests/CSharpDB.Observability.TrimSmoke/CSharpDB.Observability.TrimSmoke.csproj -c Release -r ${{ matrix.rid }} --self-contained true -p:PublishAot=true -o artifacts/observability-aot/${{ matrix.rid }} diff --git a/CSharpDB.slnx b/CSharpDB.slnx index 3c9cac40..93edaf2c 100644 --- a/CSharpDB.slnx +++ b/CSharpDB.slnx @@ -86,6 +86,7 @@ + @@ -115,6 +116,7 @@ + diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index fc196c18..ebfe1942 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,47 +1,45 @@ # What's New -## 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. -- Preserve committed row-count metadata during writes and restore it after - publication, avoiding unnecessary full-table scans for reader `COUNT(*)` queries. -- 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. +## CSharpDB 4.7.0 + +CSharpDB 4.7.0 establishes shared SDK and ADO.NET gRPC connection/session handling +and fixes archive-backed SQL in the compiled native library. +These notes cover changes since v4.6.6. + +### Remote client foundation + +- Share physical gRPC clients between SDK rentals and pooled ADO.NET connections + with matching options. Bound pool capacity, honor cancellation while waiting, + and isolate endpoints, credentials, routes, and caller-owned HTTP clients. +- Add explicit native HTTP/2 gRPC selection and experimental persistent command + sessions for SQL and transactions. Existing Auto/unary defaults remain in + place; streaming requires an explicit opt-in. +- Preserve transaction ownership, private temporary-table state, result schemas, + and logical column types across rentals and sessions. Drain active operations + before returning a client, reject stale rentals, and clean up unfinished + transactions without automatically replaying commands after disconnect. +- Prevent a queued transaction begin from blocking a schema owner's commit or + rollback. Cancel and drain pending begins during client disposal, and clean up + transactions that cannot be registered after cancellation or shutdown. +- Preserve the selected gRPC call mode when Admin clones routed client options. + +### Archive and dependency fixes + +- Fix external archive table registration in the NativeAOT library by generating + schema and manifest JSON metadata. Keep existing archive JSON bytes, enum + validation, legacy omitted-field defaults, and explicit-null rejection. +- Add a published NativeAOT archive SQL smoke covering scans, primary-key lookup, + declared types, and close/reopen persistence through the C API. Fail native + publication on trimming or AOT warnings. +- Support private migration reject artifacts on Linux tmpfs, including RAM-backed + temporary directories, while retaining owner, permission, link and ACL checks. +- Update the optional SQL Server migration adapter's PKCS dependency to 9.0.22 + and keep its exact bundle inventories and third-party notice aligned. +- Include all 27 solution test modules in the ordinary pull-request CI matrix. + +### Scope and support + +Persistent streaming remains experimental and opt-in. This release does not +include the custom TCP prototype or remote prepared-statement handles, and no +verified network performance improvement is claimed. The native archive smoke +checks the C API; it does not establish remote gRPC support under NativeAOT. diff --git a/docs/releases/changelog-publication.json b/docs/releases/changelog-publication.json index cd16a70a..413501c2 100644 --- a/docs/releases/changelog-publication.json +++ b/docs/releases/changelog-publication.json @@ -3,8 +3,8 @@ "verifiedOn": "2026-10-12", "source": "https://api.github.com/repos/MaxAkbar/CSharpDB/releases", "notesReview": { - "version": "4.6.6", - "sha256": "0c44953b85d05e17495662f40795fd2704124b9c5fbeba0d0e9fb960218c3628" + "version": "4.7.0", + "sha256": "d7e25f8ffa9e33627e37c50bcd3b4df48ff040ed41b68519b8d57343da196ea1" }, "releases": [ { diff --git a/scripts/Publish-CSharpDbSqlServerMigrationBundle.ps1 b/scripts/Publish-CSharpDbSqlServerMigrationBundle.ps1 index 7fb41457..4187d6b6 100644 --- a/scripts/Publish-CSharpDbSqlServerMigrationBundle.ps1 +++ b/scripts/Publish-CSharpDbSqlServerMigrationBundle.ps1 @@ -161,7 +161,7 @@ function Assert-ReviewedWorkerPackageClosure { '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.18' + 'System.Security.Cryptography.Pkcs/9.0.22' 'System.Security.Cryptography.ProtectedData/9.0.18' 'System.Threading.RateLimiting/9.0.18' ) | Sort-Object diff --git a/scripts/Test-SqlServerMigrationIsolation.ps1 b/scripts/Test-SqlServerMigrationIsolation.ps1 index 188a53e1..d9421f9e 100644 --- a/scripts/Test-SqlServerMigrationIsolation.ps1 +++ b/scripts/Test-SqlServerMigrationIsolation.ps1 @@ -370,7 +370,7 @@ function Assert-ReviewedWorkerPackageClosure { '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.18' + 'System.Security.Cryptography.Pkcs/9.0.22' 'System.Security.Cryptography.ProtectedData/9.0.18' 'System.Threading.RateLimiting/9.0.18' ) | Sort-Object diff --git a/src/CSharpDB.Admin/Services/DatabaseClientHolder.cs b/src/CSharpDB.Admin/Services/DatabaseClientHolder.cs index ca621028..779e6322 100644 --- a/src/CSharpDB.Admin/Services/DatabaseClientHolder.cs +++ b/src/CSharpDB.Admin/Services/DatabaseClientHolder.cs @@ -663,6 +663,8 @@ private static CSharpDbClientOptions CloneOptions( ConnectionString = options.ConnectionString, DataSource = options.DataSource, HttpClient = options.HttpClient, + GrpcMode = options.GrpcMode, + GrpcCallMode = options.GrpcCallMode, ApiKey = options.ApiKey, ApiKeyHeaderName = options.ApiKeyHeaderName, RouteContext = routeContext, diff --git a/src/CSharpDB.Client/CSharpDB.Client.csproj b/src/CSharpDB.Client/CSharpDB.Client.csproj index 711808e0..d3653299 100644 --- a/src/CSharpDB.Client/CSharpDB.Client.csproj +++ b/src/CSharpDB.Client/CSharpDB.Client.csproj @@ -5,7 +5,7 @@ enable enable CSharpDB.Client - Unified CSharpDB client SDK with pluggable transports (Direct, HTTP, gRPC, TCP, Named Pipes). + Unified CSharpDB client SDK with Direct, HTTP and gRPC transports. @@ -20,6 +20,9 @@ + + + diff --git a/src/CSharpDB.Client/CSharpDbClient.cs b/src/CSharpDB.Client/CSharpDbClient.cs index 70e79d19..803f4358 100644 --- a/src/CSharpDB.Client/CSharpDbClient.cs +++ b/src/CSharpDB.Client/CSharpDbClient.cs @@ -16,7 +16,8 @@ public sealed class CSharpDbClient : ICSharpDbTableArchiveProgressExporter, ICSharpDbTransactionalSnapshotReader, ICSharpDbExclusiveSessionProvider, - ICSharpDbTransactionalSchemaIdentityWriter + ICSharpDbTransactionalSchemaIdentityWriter, + IScalarProjectionClient { private readonly ICSharpDbClient _inner; @@ -31,6 +32,24 @@ public static ICSharpDbClient Create(CSharpDbClientOptions options) return new CSharpDbClient(ClientTransportResolver.Create(options)); } + /// Rents a validated gRPC connection. Dispose the client to return it to the shared pool. + public static async ValueTask RentAsync( + CSharpDbClientOptions options, int maxPoolSize = 32, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(options); + return new CSharpDbClient(await GrpcClientPoolRegistry.RentAsync(options, maxPoolSize, ct).ConfigureAwait(false)); + } + + /// Retires all gRPC pools for an endpoint. Active rentals are disposed on return. + public static ValueTask ClearPoolAsync(string endpoint) + { + ArgumentException.ThrowIfNullOrWhiteSpace(endpoint); + return GrpcClientPoolRegistry.ClearAsync(endpoint.Trim()); + } + + /// Retires all shared gRPC pools. + public static ValueTask ClearAllPoolsAsync() => GrpcClientPoolRegistry.ClearAsync(); + public static ICSharpDbShardAdminClient CreateShardAdmin(CSharpDbClientOptions options) { ArgumentNullException.ThrowIfNull(options); @@ -58,6 +77,13 @@ public static ICSharpDbShardDirectoryClient CreateShardDirectoryClient(CSharpDbC } public string DataSource => _inner.DataSource; + bool IScalarProjectionClient.SupportsScalarProjection + => _inner is IScalarProjectionClient { SupportsScalarProjection: true }; + ValueTask IScalarProjectionClient.ExecuteScalarProjectionAsync( + string sql, string? transactionId, CancellationToken cancellationToken) + => _inner is IScalarProjectionClient { SupportsScalarProjection: true } scalar + ? scalar.ExecuteScalarProjectionAsync(sql, transactionId, cancellationToken) + : throw new NotSupportedException("This connection does not support scalar projection."); public Task ReadDefinitionCatalogAsync(string? continuationToken = null, int pageSize = 64, CancellationToken ct = default) => _inner is ICSharpDbDefinitionCatalogReader reader ? reader.ReadDefinitionCatalogAsync(continuationToken, pageSize, ct) diff --git a/src/CSharpDB.Client/CSharpDbClientOptions.cs b/src/CSharpDB.Client/CSharpDbClientOptions.cs index ec2f26bb..a1c7760f 100644 --- a/src/CSharpDB.Client/CSharpDbClientOptions.cs +++ b/src/CSharpDB.Client/CSharpDbClientOptions.cs @@ -9,6 +9,8 @@ public sealed class CSharpDbClientOptions public string? ConnectionString { get; init; } public string? DataSource { get; init; } public HttpClient? HttpClient { get; init; } + public CSharpDbGrpcMode GrpcMode { get; init; } = CSharpDbGrpcMode.Auto; + public CSharpDbGrpcCallMode GrpcCallMode { get; init; } = CSharpDbGrpcCallMode.Unary; public string? ApiKey { get; init; } public string ApiKeyHeaderName { get; init; } = "X-CSharpDB-Api-Key"; public CSharpDbRouteContext? RouteContext { get; init; } diff --git a/src/CSharpDB.Client/CSharpDbGrpcCallMode.cs b/src/CSharpDB.Client/CSharpDbGrpcCallMode.cs new file mode 100644 index 00000000..5df64e07 --- /dev/null +++ b/src/CSharpDB.Client/CSharpDbGrpcCallMode.cs @@ -0,0 +1,10 @@ +namespace CSharpDB.Client; + +/// Selects how SQL and transaction commands travel over native gRPC. +public enum CSharpDbGrpcCallMode +{ + Unary = 0, + + /// Experimental persistent command stream; requires GrpcMode.Native. + Streaming = 1, +} diff --git a/src/CSharpDB.Client/CSharpDbGrpcMode.cs b/src/CSharpDB.Client/CSharpDbGrpcMode.cs new file mode 100644 index 00000000..64658161 --- /dev/null +++ b/src/CSharpDB.Client/CSharpDbGrpcMode.cs @@ -0,0 +1,17 @@ +namespace CSharpDB.Client; + +/// Controls the HTTP protocol used by the gRPC transport. +public enum CSharpDbGrpcMode +{ + /// + /// Preserves compatibility: plain HTTP uses gRPC-Web over HTTP/1.1 unless + /// a custom HttpClient is supplied; HTTPS uses native gRPC. + /// + Auto = 0, + + /// + /// Uses native gRPC and requires HTTP/2, including on a plain HTTP endpoint. + /// An unencrypted server endpoint must be configured for HTTP/2 only. + /// + Native = 1, +} diff --git a/src/CSharpDB.Client/Grpc/GrpcModelMapper.cs b/src/CSharpDB.Client/Grpc/GrpcModelMapper.cs index 8ac9ebad..c22cec03 100644 --- a/src/CSharpDB.Client/Grpc/GrpcModelMapper.cs +++ b/src/CSharpDB.Client/Grpc/GrpcModelMapper.cs @@ -571,26 +571,76 @@ public static ViewBrowseResult ToModel(ViewBrowseResultMessage value) }; public static SqlExecutionResultMessage ToMessage(SqlExecutionResult value) + => ToMessage(value, includeMetadata: true, allowCompactScalarInt64: false); + + public static SqlExecutionResultMessage ToMessage(SqlExecutionResult value, bool includeMetadata) + => ToMessage(value, includeMetadata, allowCompactScalarInt64: false); + + public static SqlExecutionResultMessage ToMessage(SqlExecutionResult value, bool includeMetadata, bool allowCompactScalarInt64) { + long scalar = 0; + bool compact = allowCompactScalarInt64 && TryGetSingleInt64Result(value, out scalar); var message = new SqlExecutionResultMessage { IsQuery = value.IsQuery, - ColumnNames = value.ColumnNames is null ? null : ToStringList(value.ColumnNames), - ColumnTypes = value.ColumnTypes is null ? null : ToStringList(value.ColumnTypes), - ColumnNullability = value.ColumnNullability is null + ColumnNames = !includeMetadata || value.ColumnNames is null ? null : ToStringList(value.ColumnNames), + ColumnTypes = !includeMetadata || value.ColumnTypes is null ? null : ToStringList(value.ColumnTypes), + ColumnNullability = !includeMetadata || value.ColumnNullability is null ? null : ToBoolList(value.ColumnNullability), - Rows = value.Rows is null ? null : ToVariantArrayList(value.Rows), + Rows = compact || value.Rows is null ? null : ToVariantArrayList(value.Rows), RowsAffected = value.RowsAffected, Error = value.Error, Elapsed = Duration.FromTimeSpan(value.Elapsed), }; - if (value.Columns is not null) + if (includeMetadata && value.Columns is not null) message.Columns.Add(value.Columns.Select(ToMessage)); + if (compact) message.ScalarInt64 = scalar; return message; } public static SqlExecutionResult ToModel(SqlExecutionResultMessage value) + { + if (value.HasScalarInt64) + throw new CSharpDbClientException("A compact SQL scalar requires a negotiated command session."); + return ToSqlExecutionResultModel(value); + } + + internal static SqlExecutionResult ToSessionModel(SqlExecutionResultMessage value) + => ToSessionModel(value, metadata: null); + + internal static SqlExecutionResult ToSessionModel(SqlExecutionResultMessage value, GrpcSessionResultMetadata? metadata) + { + if (value.HasScalarInt64 && (!value.IsQuery || value.Error is not null || value.Rows is not null + || !(metadata?.HasSingleColumnMetadata ?? HasSingleColumnMetadata(value)))) + throw new CSharpDbClientException("The command session returned an invalid compact SQL scalar."); + return metadata is null ? ToSqlExecutionResultModel(value) : ToSqlExecutionResultModel(value, metadata); + } + + internal static bool HasSingleColumnMetadata(SqlExecutionResultMessage value) + => (value.ColumnNames?.Values.Count == 1 || value.Columns.Count == 1) + && (value.ColumnNames is null || value.ColumnNames.Values.Count == 1) + && (value.ColumnTypes is null || value.ColumnTypes.Values.Count == 1) + && (value.ColumnNullability is null || value.ColumnNullability.Values.Count == 1) + && value.Columns.Count <= 1; + + private static bool TryGetSingleInt64Result(SqlExecutionResult value, out long scalar) + { + scalar = 0; + if (!value.IsQuery || value.Error is not null || value.Rows is not { Count: 1 } + || value.Rows[0] is not { Length: 1 } row || row[0] is not long number) + return false; + if (!(value.ColumnNames is { Length: 1 } || value.Columns is { Length: 1 }) + || (value.ColumnNames is not null && value.ColumnNames.Length != 1) + || (value.ColumnTypes is not null && value.ColumnTypes.Length != 1) + || (value.ColumnNullability is not null && value.ColumnNullability.Length != 1) + || (value.Columns is not null && value.Columns.Length != 1)) + return false; + scalar = number; + return true; + } + + private static SqlExecutionResult ToSqlExecutionResultModel(SqlExecutionResultMessage value) => new() { IsQuery = value.IsQuery, @@ -600,7 +650,25 @@ public static SqlExecutionResult ToModel(SqlExecutionResultMessage value) Columns = value.Columns.Count == 0 ? null : value.Columns.Select(ToModel).ToArray(), - Rows = value.Rows is null ? null : ToRows(value.Rows), + Rows = value.HasScalarInt64 + ? new List(1) { new object?[] { value.ScalarInt64 } } + : value.Rows is null ? null : ToRows(value.Rows), + RowsAffected = value.RowsAffected, + Error = value.Error, + Elapsed = value.Elapsed.ToTimeSpan(), + }; + + private static SqlExecutionResult ToSqlExecutionResultModel(SqlExecutionResultMessage value, GrpcSessionResultMetadata metadata) + => new() + { + IsQuery = value.IsQuery, + ColumnNames = metadata.CreateColumnNames(), + ColumnTypes = metadata.CreateColumnTypes(), + ColumnNullability = metadata.CreateColumnNullability(), + Columns = metadata.CreateColumns(), + Rows = value.HasScalarInt64 + ? new List(1) { new object?[] { value.ScalarInt64 } } + : value.Rows is null ? null : ToRows(value.Rows), RowsAffected = value.RowsAffected, Error = value.Error, Elapsed = value.Elapsed.ToTimeSpan(), diff --git a/src/CSharpDB.Client/Grpc/GrpcSessionResultMetadata.cs b/src/CSharpDB.Client/Grpc/GrpcSessionResultMetadata.cs new file mode 100644 index 00000000..7a2f72f1 --- /dev/null +++ b/src/CSharpDB.Client/Grpc/GrpcSessionResultMetadata.cs @@ -0,0 +1,85 @@ +using CSharpDB.Client.Models; +using CSharpDB.Client.Internal; + +namespace CSharpDB.Client.Grpc; + +/// Private canonical mapped metadata for one validated session schema. +internal sealed class GrpcSessionResultMetadata +{ + private readonly string[]? _names; + private readonly string[]? _types; + private readonly bool[]? _nullability; + private readonly ColumnDefinition[]? _columns; + private readonly bool _hasScalarProjectionMetadata; + private readonly ScalarTypeSnapshot? _scalarType; + + internal bool HasSingleColumnMetadata { get; } + + internal GrpcSessionResultMetadata(SqlExecutionResultMessage definition) + { + HasSingleColumnMetadata = GrpcModelMapper.HasSingleColumnMetadata(definition); + _names = definition.ColumnNames?.Values.ToArray(); + _types = definition.ColumnTypes?.Values.ToArray(); + _nullability = definition.ColumnNullability?.Values.ToArray(); + if (definition.Columns.Count != 0) + { + _columns = new ColumnDefinition[definition.Columns.Count]; + for (int i = 0; i < _columns.Length; i++) + _columns[i] = GrpcModelMapper.ToModel(definition.Columns[i]); + } + + _hasScalarProjectionMetadata = HasSingleColumnMetadata + && _names is { Length: 1 } && _columns is { Length: 1 }; + if (_hasScalarProjectionMetadata && _columns![0].DeclaredType is { } type) + { + _scalarType = new ScalarTypeSnapshot( + type.Kind, type.Length, type.Precision, type.Scale, type.FractionalSecondsPrecision); + } + } + + internal bool TryGetScalarType(out ScalarTypeSnapshot? declaredType) + { + declaredType = _scalarType; + return _hasScalarProjectionMetadata; + } + + internal string[]? CreateColumnNames() + => _names is null ? null : (string[])_names.Clone(); + + internal string[]? CreateColumnTypes() + => _types is null ? null : (string[])_types.Clone(); + + internal bool[]? CreateColumnNullability() + => _nullability is null ? null : (bool[])_nullability.Clone(); + + internal ColumnDefinition[]? CreateColumns() + { + if (_columns is null) return null; + var columns = new ColumnDefinition[_columns.Length]; + for (int i = 0; i < columns.Length; i++) + { + ColumnDefinition canonical = _columns[i]; + columns[i] = new ColumnDefinition + { + SchemaId = canonical.SchemaId, + Name = canonical.Name, + Type = canonical.Type, + DeclaredType = canonical.DeclaredType is { } type ? new SqlTypeDescriptor + { + Kind = type.Kind, + Length = type.Length, + Precision = type.Precision, + Scale = type.Scale, + FractionalSecondsPrecision = type.FractionalSecondsPrecision, + } : null, + Nullable = canonical.Nullable, + IsPrimaryKey = canonical.IsPrimaryKey, + IsIdentity = canonical.IsIdentity, + IsRowVersion = canonical.IsRowVersion, + Collation = canonical.Collation, + DefaultSql = canonical.DefaultSql, + }; + } + return columns; + } +} diff --git a/src/CSharpDB.Client/Internal/ClientTransportResolver.cs b/src/CSharpDB.Client/Internal/ClientTransportResolver.cs index 0e04a034..93f1a488 100644 --- a/src/CSharpDB.Client/Internal/ClientTransportResolver.cs +++ b/src/CSharpDB.Client/Internal/ClientTransportResolver.cs @@ -27,7 +27,9 @@ public static ICSharpDbClient Create(CSharpDbClientOptions options) options.HttpClient, options.ApiKey, options.ApiKeyHeaderName, - options.RouteContext), + options.RouteContext, + options.GrpcMode, + options.GrpcCallMode), CSharpDbTransport.NamedPipes => throw CreateNotImplementedTransportException(CSharpDbTransport.NamedPipes), _ => throw new CSharpDbClientConfigurationException($"Unsupported transport '{resolution.Transport}'."), }; @@ -41,6 +43,15 @@ private static Resolution Resolve(CSharpDbClientOptions options) endpointUri = parsedEndpointUri; var transport = options.Transport ?? InferTransport(endpoint, endpointUri); + if (!Enum.IsDefined(options.GrpcMode)) + throw new CSharpDbClientConfigurationException($"Unsupported gRPC mode '{options.GrpcMode}'."); + if (!Enum.IsDefined(options.GrpcCallMode)) + throw new CSharpDbClientConfigurationException($"Unsupported gRPC call mode '{options.GrpcCallMode}'."); + if (options.GrpcCallMode == CSharpDbGrpcCallMode.Streaming && + (transport != CSharpDbTransport.Grpc || options.GrpcMode != CSharpDbGrpcMode.Native)) + throw new CSharpDbClientConfigurationException("Streaming gRPC requires the Grpc transport and GrpcMode.Native."); + if (options.GrpcMode != CSharpDbGrpcMode.Auto && transport != CSharpDbTransport.Grpc) + throw new CSharpDbClientConfigurationException("GrpcMode is only supported for the Grpc transport."); return transport switch { CSharpDbTransport.Direct => ResolveDirect(options, ResolveDirectEndpointPath(endpoint, endpointUri)), @@ -51,6 +62,14 @@ private static Resolution Resolve(CSharpDbClientOptions options) }; } + internal static Uri ResolvePooledGrpcEndpoint(CSharpDbClientOptions options) + { + var resolution = Resolve(options); + if (resolution.Transport != CSharpDbTransport.Grpc) + throw new CSharpDbClientConfigurationException("Client pooling requires the Grpc transport."); + return resolution.EndpointUri!; + } + private static Resolution ResolveDirect(CSharpDbClientOptions options, string? endpointPath) { DirectTarget? endpointTarget = endpointPath is null diff --git a/src/CSharpDB.Client/Internal/EngineTransportClient.DiagnosticsCapability.cs b/src/CSharpDB.Client/Internal/EngineTransportClient.DiagnosticsCapability.cs index a910f557..4ec42f55 100644 --- a/src/CSharpDB.Client/Internal/EngineTransportClient.DiagnosticsCapability.cs +++ b/src/CSharpDB.Client/Internal/EngineTransportClient.DiagnosticsCapability.cs @@ -1883,7 +1883,7 @@ private int TryGetCachedDatabaseActiveReaderCount() try { - return databaseTask.GetAwaiter().GetResult().ActiveReaderCount; + return databaseTask.GetAwaiter().GetResult().ActiveQueryReaderCount; } catch { diff --git a/src/CSharpDB.Client/Internal/EngineTransportClient.ExclusiveSession.cs b/src/CSharpDB.Client/Internal/EngineTransportClient.ExclusiveSession.cs index 7dd2b7da..3e92aa9c 100644 --- a/src/CSharpDB.Client/Internal/EngineTransportClient.ExclusiveSession.cs +++ b/src/CSharpDB.Client/Internal/EngineTransportClient.ExclusiveSession.cs @@ -18,7 +18,8 @@ public async ValueTask OpenExclusiveSessionAsync(Cance EngineTransportClient? session = null; try { - if (_activeFinalizations != 0) throw new InvalidOperationException("Wait for transaction completion before starting an exclusive operation."); + if (_activeFinalizations != 0 || _pendingTransactionBegins != 0) + throw new InvalidOperationException("Wait for transaction admission/completion before starting an exclusive operation."); var source = _directDatabaseOptions; var storage = source.StorageEngineOptions; var options = new DatabaseOptions diff --git a/src/CSharpDB.Client/Internal/EngineTransportClient.cs b/src/CSharpDB.Client/Internal/EngineTransportClient.cs index 2146b6a0..212b56ad 100644 --- a/src/CSharpDB.Client/Internal/EngineTransportClient.cs +++ b/src/CSharpDB.Client/Internal/EngineTransportClient.cs @@ -70,6 +70,9 @@ private HashSet? private readonly object _databaseGate = new(); private readonly object _disposeGate = new(); private readonly ConcurrentDictionary _transactions = new(StringComparer.Ordinal); + private readonly CancellationTokenSource _transactionAdmissionsShutdown = new(); + private int _pendingTransactionBegins; + private TaskCompletionSource? _transactionBeginsDrained; private int _activeFinalizations; private TaskCompletionSource? _finalizationsDrained; private Task? _databaseTask; @@ -78,7 +81,6 @@ private HashSet? private Task? _disposeTask; private bool _disposeStarted; private int _exclusiveMaintenanceActive; - private long _databaseOwnershipEpoch; private bool _catalogsInitialized; private List? _clientLockBoundaryStates; @@ -664,85 +666,87 @@ public async Task BeginTransactionAsync(CancellationToke { using ClientLockLease clientLock = await AcquireClientLockAsync(ct); ThrowIfDisposing(); - long reuseEpoch = CaptureDatabaseOwnershipEpoch(); - Database? database = null; - if (_hybridDatabaseOptions is null) + // Initialize client metadata before taking a writer snapshot. Otherwise + // the first ordinary command could wait for schema access held by the + // session whose caller is waiting for that command to finish. + await EnsureCatalogsInitializedAsync(ct); + Database database = await GetDatabaseAsync(ct); + if (SnapshotSessionRequiresExclusiveAccess && database.ActiveReaderCount > 0) + throw new CSharpDbClientException( + "Snapshot hybrid transactions require exclusive access. Close active snapshot readers first."); + + using var admission = CancellationTokenSource.CreateLinkedTokenSource( + ct, _transactionAdmissionsShutdown.Token); + RegisterPendingTransactionBegin(); + // Schema admission can depend on another session committing. Pin the + // cached owner, but let completion/disposal acquire the client lock. + clientLock.ReleaseLock(); + WriteTransaction? transaction = null; + ClientTransactionSession? session = null; + bool registered = false; + try { - database = await DetachCachedDatabaseCoreAsync( - ct, - "Cannot start a client-managed transaction while direct snapshot readers are active."); - if (database is not null) + transaction = await database.BeginWriteTransactionAsync(admission.Token); + session = new ClientTransactionSession( + database, + transaction, + _observabilityEnabled + ? database.RuntimeDiagnosticsState?.TimeProvider ?? + _observabilityTimeProvider + : null, + database.RuntimeDiagnosticsState?.SessionAbandonmentThreshold ?? + _directDatabaseOptions.ObservabilityOptions?.SessionAbandonmentThreshold ?? + TimeSpan.FromMinutes(30)); + + using ClientLockLease registrationLock = await AcquireClientLockAsync(admission.Token); + ThrowIfDisposing(); + ct.ThrowIfCancellationRequested(); + string transactionId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + if (!_transactions.TryAdd(transactionId, session)) + throw new CSharpDbClientException("Failed to register the transaction session."); + try { - try - { - await database.ResetReusableSessionStateAsync(); - } - catch - { - await database.DisposeAsync(); - throw; - } + RetainRuntimeDiagnosticsState(session); } - } - else - { - // Hybrid persistence may be configured to run only on Dispose. - // Preserve that physical-close boundary rather than retaining - // the handle across logical transaction sessions. - await ReleaseCachedDatabaseCoreAsync( - ct, - "Cannot start a client-managed transaction while direct snapshot readers are active."); - } + catch + { + _transactions.TryRemove(transactionId, out _); + throw; + } + registered = true; - database ??= await OpenOwnedDatabaseAsync(_databasePath, ct); - try + return new TransactionSessionInfo + { + TransactionId = transactionId, + ExpiresAtUtc = DateTime.MaxValue, + }; + } + catch (OperationCanceledException exception) when (ct.IsCancellationRequested) { - await database.BeginTransactionAsync(ct); + throw new OperationCanceledException(exception.Message, exception, ct); } - catch + catch (OperationCanceledException) when (_transactionAdmissionsShutdown.IsCancellationRequested) { - await database.DisposeAsync(); - throw; + throw new ObjectDisposedException(nameof(EngineTransportClient)); } - - string transactionId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); - var session = new ClientTransactionSession( - database, - reuseEpoch, - _observabilityEnabled - ? database.RuntimeDiagnosticsState?.TimeProvider ?? - _observabilityTimeProvider - : null, - database.RuntimeDiagnosticsState?.SessionAbandonmentThreshold ?? - _directDatabaseOptions.ObservabilityOptions?.SessionAbandonmentThreshold ?? - TimeSpan.FromMinutes(30)); - if (!_transactions.TryAdd(transactionId, session)) + finally { try { - await database.RollbackAsync(CancellationToken.None); + if (!registered) + { + if (session is not null) + await DisposeTransactionSessionAsync(session); + else if (transaction is not null) + await transaction.DisposeAsync(); + } } finally { - await database.DisposeAsync(); + using ClientLockLease admissionLock = await AcquireClientLockAsync(CancellationToken.None); + UnregisterPendingTransactionBegin(); } - - throw new CSharpDbClientException("Failed to register the transaction session."); - } - - RetainRuntimeDiagnosticsState(session); - - if (_transactions.Count > 1) - { - foreach (ClientTransactionSession activeSession in _transactions.Values) - activeSession.DisableReuse(); } - - return new TransactionSessionInfo - { - TransactionId = transactionId, - ExpiresAtUtc = DateTime.MaxValue, - }; } public async Task ExecuteInTransactionAsync(string transactionId, string sql, CancellationToken ct = default) @@ -786,7 +790,8 @@ public async Task ExecuteInTransactionAsync(string transacti using IDisposable? queueDurationScope = operation?.EnterQueueDurationScope(); queryDispatched = true; - return await ExecuteQueryAsync(session.Database, sql, ct); + await using var result = await session.Transaction.ExecuteAsync(sql, ct); + return await MaterializeQueryResultAsync(result, ct); } catch (Exception exception) { @@ -828,11 +833,11 @@ public async Task ExecuteInTransactionAsync(string transacti try { - CoreTableSchema? schema = session.Database.GetTableSchema(normalizedTableName); + CoreTableSchema? schema = session.Transaction.GetTableSchema(normalizedTableName); if (schema is null) return null; - IndexSchema[] indexes = session.Database.GetIndexes() + IndexSchema[] indexes = session.Transaction.GetIndexes() .Where(index => index.Kind is not ( CSharpDB.Primitives.IndexKind.ForeignKeyInternal or @@ -845,10 +850,10 @@ CSharpDB.Primitives.IndexKind.ForeignKeyInternal or return new TransactionTableSnapshot { HasUpdateHostCallbacks = _directDatabaseOptions.StorageEngineOptions.PagerOptions.Interceptors.Count != 0 - || session.Database.HasUpdateHostCallbacks(normalizedTableName), + || session.Transaction.HasUpdateHostCallbacks(normalizedTableName), Schema = MapTableSchema(schema), Indexes = indexes, - Triggers = session.Database.GetTriggers() + Triggers = session.Transaction.GetTriggers() .Where(trigger => string.Equals(trigger.TableName, normalizedTableName, StringComparison.OrdinalIgnoreCase)) .Select(MapTriggerSchema).ToArray(), }; @@ -880,7 +885,7 @@ public async ValueTask ApplyTableSchemaIdentitiesAsync( try { - await session.Database.ApplyTableSchemaIdentitiesAsync( + await session.Transaction.ApplyTableSchemaIdentitiesAsync( normalizedTableName, MapCoreTableSchema(identitySource), ct); @@ -999,7 +1004,7 @@ await session.Database.ApplyTableSchemaIdentitiesAsync( using IDisposable? queueDurationScope = operation?.EnterQueueDurationScope(); queryDispatched = true; - result = await session.Database.ExecuteAsync(sql, ct); + result = await session.Transaction.ExecuteAsync(sql, ct); if (!result.IsQuery) return null; @@ -1235,6 +1240,7 @@ private async Task DisposeCoreAsync() ClientLockLease lifecycleBoundaryLock = await AcquireClientLockAsync(CancellationToken.None); List sessionsToDispose; + Task? transactionBeginsDrained; Task? finalizationsDrained; Task? maintenanceLifetimesDrained; try @@ -1253,6 +1259,7 @@ private async Task DisposeCoreAsync() sessionsToDispose.Add(session); } + transactionBeginsDrained = _transactionBeginsDrained?.Task; finalizationsDrained = GetFinalizationsDrainedTask(); maintenanceLifetimesDrained = GetMaintenanceLifetimesDrainedTask(); @@ -1267,29 +1274,44 @@ private async Task DisposeCoreAsync() { try { - await Task.WhenAll(sessionsToDispose.Select(DisposeClaimedSessionAsync)); + try + { + // Cancellation callbacks may reenter the client, so never + // invoke them while holding the lifecycle lock. + _transactionAdmissionsShutdown.Cancel(); + } + finally + { + await Task.WhenAll(sessionsToDispose.Select(DisposeClaimedSessionAsync)); + } } finally { + if (transactionBeginsDrained is not null) + await transactionBeginsDrained; if (finalizationsDrained is not null) await finalizationsDrained; - if (maintenanceLifetimesDrained is not null) - await maintenanceLifetimesDrained; - } - - using (ClientLockLease finalLock = - await AcquireClientLockAsync(CancellationToken.None)) - { - if (_databaseTask is not null) + try { - try - { - var db = await _databaseTask; - await db.DisposeAsync(); - } - catch + if (maintenanceLifetimesDrained is not null) + await maintenanceLifetimesDrained; + } + finally + { + using ClientLockLease finalLock = + await AcquireClientLockAsync(CancellationToken.None); + if (_databaseTask is not null) { - // ignore lazy-init failures during dispose + try + { + Database database = await _databaseTask; + await database.DisposeAsync(); + } + catch + { + // Preserve best-effort cached-owner disposal. Errors + // finishing logical transaction sessions propagate above. + } } } } @@ -1318,6 +1340,7 @@ await AcquireClientLockAsync(CancellationToken.None)) s_disposeFlushToken.Value = previousToken; DisposeClientMaintenanceDiagnostics(); DisposeRuntimeDiagnosticsStates(); + _transactionAdmissionsShutdown.Dispose(); _lock.Dispose(); } } @@ -1606,8 +1629,22 @@ internal void CompleteOpen() } } + private bool SnapshotSessionRequiresExclusiveAccess => + _hybridDatabaseOptions?.PersistenceMode == HybridPersistenceMode.Snapshot && + (_hybridDatabaseOptions.PersistenceTriggers & + (HybridPersistenceTriggers.Commit | HybridPersistenceTriggers.Dispose)) != 0; + private async Task GetDatabaseAsync(CancellationToken ct) { + // Full-image hybrid persistence requires exclusive access at session + // completion. Incremental-durable hybrid and file owners allow overlap. + if (SnapshotSessionRequiresExclusiveAccess && + (!_transactions.IsEmpty || Volatile.Read(ref _activeFinalizations) != 0 || + Volatile.Read(ref _pendingTransactionBegins) != 0)) + { + throw new CSharpDB.Primitives.CSharpDbException(CSharpDB.Primitives.ErrorCode.Busy, + "Finish the active snapshot hybrid transaction before using the ordinary session or starting another transaction."); + } using IDisposable? operationalBoundary = EnterDirectOperationalTransportScope(); @@ -1637,7 +1674,6 @@ private async Task GetDatabaseAsync(CancellationToken ct) var openCompletion = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); createdOpenTask = openCompletion.Task; - _databaseOwnershipEpoch++; _databaseTask = createdOpenTask; if (openBoundary is not null) { @@ -1785,10 +1821,10 @@ private async Task AcquireExclusiveDatabaseAccessA try { ThrowIfDisposing(); - if (activeTransactionMessage is not null && !_transactions.IsEmpty) + if (!_transactions.IsEmpty || _activeFinalizations != 0 || _pendingTransactionBegins != 0) { throw new ExclusiveDatabaseAccessRejectedException( - activeTransactionMessage); + activeTransactionMessage ?? "Finish active transactions before starting an exclusive operation."); } maintenanceOperation?.SetPhase( @@ -1923,7 +1959,6 @@ private async ValueTask ReopenAndCacheExclusiveDatabaseAsync( "Exclusive database access ended before the restored database could be cached."); } - _databaseOwnershipEpoch++; _catalogsInitialized = false; _databaseTask = Task.FromResult(database); database = null; @@ -2128,18 +2163,18 @@ private sealed class ClientTransactionSession private long _lastActiveTimestamp; private int _activeGateOperations; private int _activeDiagnosticsReaders; - private int _reuseDisabled; private int _runtimeDiagnosticsStateOwnershipReleased; private int _state; public ClientTransactionSession( Database database, - long reuseEpoch, + WriteTransaction transaction, TimeProvider? diagnosticsTimeProvider, TimeSpan diagnosticsAbandonmentThreshold) { Database = database; - ReuseEpoch = reuseEpoch; + Transaction = transaction; + transaction.SetTemporaryTableSession(this); _diagnosticsTimeProvider = diagnosticsTimeProvider; _diagnosticsAbandonmentThreshold = diagnosticsAbandonmentThreshold; if (diagnosticsTimeProvider is not null) @@ -2156,14 +2191,11 @@ public ClientTransactionSession( } public Database Database { get; } - public long ReuseEpoch { get; } - public bool ReuseAllowed => Volatile.Read(ref _reuseDisabled) == 0; + public WriteTransaction Transaction { get; } public bool CanPublishDiagnostics => _diagnosticsTimeProvider is not null; public bool HasActiveDiagnosticsReader => Volatile.Read(ref _activeDiagnosticsReaders) > 0; - public void DisableReuse() => Volatile.Write(ref _reuseDisabled, 1); - public async ValueTask TryEnterOperationAsync(CancellationToken ct) { if (Volatile.Read(ref _state) != 0) @@ -2383,8 +2415,17 @@ public TimeSpan GetDiagnosticsAge() public bool TryClaimFinalization() => Interlocked.CompareExchange(ref _state, 1, 0) == 0; - public ValueTask WaitForOperationsAsync(CancellationToken ct) - => new(_operationGate.WaitAsync(ct)); + public async ValueTask WaitForOperationsAsync(CancellationToken ct) + { + await _operationGate.WaitAsync(ct); + // Cancellation can race a successful semaphore handoff. Return + // the gate before reporting cancellation to finalization recovery. + if (ct.IsCancellationRequested) + { + _operationGate.Release(); + ct.ThrowIfCancellationRequested(); + } + } public void CancelFinalizationClaim() => Volatile.Write(ref _state, 0); @@ -2859,6 +2900,11 @@ private async Task ExecuteQueryAsync( { using IDisposable? transportScope = EnterDirectTransportScope(); await using var result = await db.ExecuteAsync(sql, observabilitySql ?? sql, ct); + return await MaterializeQueryResultAsync(result, ct); + } + + private static async Task MaterializeQueryResultAsync(CSharpDB.Execution.QueryResult result, CancellationToken ct) + { var rows = await result.ToListAsync(ct); return new SqlExecutionResult { @@ -3417,6 +3463,21 @@ private void ThrowIfDisposing() throw new ObjectDisposedException(nameof(EngineTransportClient)); } + private void RegisterPendingTransactionBegin() + { + if (_pendingTransactionBegins++ == 0) + _transactionBeginsDrained = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + private void UnregisterPendingTransactionBegin() + { + if (--_pendingTransactionBegins == 0) + { + _transactionBeginsDrained!.TrySetResult(); + _transactionBeginsDrained = null; + } + } + private void RegisterFinalization() { if (_activeFinalizations++ == 0) @@ -3440,7 +3501,7 @@ private async Task DisposeClaimedSessionAsync(ClientTransactionSession session) try { await session.WaitForOperationsAsync(CancellationToken.None); - await session.Database.DisposeAsync(); + await DisposeTransactionSessionAsync(session); } finally { @@ -3452,6 +3513,20 @@ private async Task DisposeClaimedSessionAsync(ClientTransactionSession session) } } + private static async ValueTask DisposeTransactionSessionAsync(ClientTransactionSession session) + { + try + { + await session.Transaction.DisposeAsync(); + } + finally + { + using (session.Database.EnterTemporaryTableSessionScope(session)) + await session.Database.ClearTemporaryTablesAsync(); + await session.Database.PersistClientSessionBoundaryAsync(); + } + } + private async Task CompleteTransactionAsync( string transactionId, bool commit, @@ -3459,9 +3534,7 @@ private async Task CompleteTransactionAsync( { ClientLockLease lifecycleBoundaryLock = await AcquireClientLockAsync(ct); ClientTransactionSession? session = null; - bool adopted = false; bool operationGateAcquired = false; - bool sessionResetSucceeded = false; try { ThrowIfDisposing(); @@ -3490,17 +3563,20 @@ private async Task CompleteTransactionAsync( using (ClientLockLease recoveryLock = await AcquireClientLockAsync(CancellationToken.None)) { - session.CancelFinalizationClaim(); disposeClaimedSession = _disposeStarted; if (!disposeClaimedSession) + { + session.CancelFinalizationClaim(); _transactions.TryAdd(transactionId, session); + } } if (disposeClaimedSession) { try { - await session.Database.DisposeAsync(); + await session.WaitForOperationsAsync(CancellationToken.None); + await DisposeTransactionSessionAsync(session); } finally { @@ -3522,34 +3598,18 @@ await AcquireClientLockAsync(CancellationToken.None)) throw; } - Database database = session.Database; - if (commit) - await database.CommitAsync(ct); + await session.Transaction.CommitAsync(ct); else - await database.RollbackAsync(ct); - - if (_hybridDatabaseOptions is null) - { - await database.ResetReusableSessionStateAsync(); - sessionResetSucceeded = true; - } + await session.Transaction.RollbackAsync(ct); } finally { if (session is not null && operationGateAcquired) { - using (ClientLockLease adoptionLock = - await AcquireClientLockAsync(CancellationToken.None)) - { - if (!_disposeStarted && _hybridDatabaseOptions is null && sessionResetSucceeded) - adopted = TryAdoptCachedDatabase(session); - } - try { - if (!adopted) - await session.Database.DisposeAsync(); + await DisposeTransactionSessionAsync(session); } finally { @@ -3565,103 +3625,12 @@ await AcquireClientLockAsync(CancellationToken.None)) } } - private long CaptureDatabaseOwnershipEpoch() - { - lock (_databaseGate) - return _databaseOwnershipEpoch; - } - - private bool TryAdoptCachedDatabase(ClientTransactionSession session) - { - lock (_databaseGate) - { - if (!session.ReuseAllowed || - session.ReuseEpoch != _databaseOwnershipEpoch || - !ReferenceEquals( - session.Database.RuntimeDiagnosticsState, - CurrentRuntimeDiagnosticsState) || - !_transactions.IsEmpty || - _databaseTask is not null || - _databaseReleaseCompletion is { Task.IsCompleted: false }) - { - return false; - } - - _catalogsInitialized = false; - _databaseReleaseCompletion = null; - _databaseOwnershipEpoch++; - _databaseTask = Task.FromResult(session.Database); - return true; - } - } - - private async Task DetachCachedDatabaseCoreAsync( - CancellationToken ct, - string activeReaderMessage) - { - Task? openTask; - TaskCompletionSource releaseCompletion; - lock (_databaseGate) - { - openTask = _databaseTask; - _catalogsInitialized = false; - if (openTask is null) - return null; - - _databaseTask = null; - releaseCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _databaseReleaseCompletion = releaseCompletion; - } - - try - { - Database database; - try - { - database = await openTask.WaitAsync(ct); - } - catch (OperationCanceledException) when (ct.IsCancellationRequested) - { - lock (_databaseGate) - { - if (_databaseTask is null) - _databaseTask = openTask; - } - - throw; - } - catch - { - return null; - } - - if (database.ActiveReaderCount > 0) - { - lock (_databaseGate) - { - if (_databaseTask is null) - _databaseTask = openTask; - } - - throw new CSharpDbClientException(activeReaderMessage); - } - - return database; - } - finally - { - lock (_databaseGate) - { - if (ReferenceEquals(_databaseReleaseCompletion, releaseCompletion)) - _databaseReleaseCompletion = null; - } - - releaseCompletion.TrySetResult(); - } - } - private async Task ReleaseCachedDatabaseCoreAsync(CancellationToken ct, string activeReaderMessage) { + if (!_transactions.IsEmpty || _activeFinalizations != 0 || _pendingTransactionBegins != 0) + throw new CSharpDbClientException( + "Cannot release the direct database handle while client-managed transactions are active or completing."); + Task? openTask; TaskCompletionSource releaseCompletion; lock (_databaseGate) diff --git a/src/CSharpDB.Client/Internal/GrpcApiKeyClientInterceptor.cs b/src/CSharpDB.Client/Internal/GrpcApiKeyClientInterceptor.cs index 5bb9de2c..08859c13 100644 --- a/src/CSharpDB.Client/Internal/GrpcApiKeyClientInterceptor.cs +++ b/src/CSharpDB.Client/Internal/GrpcApiKeyClientInterceptor.cs @@ -20,6 +20,17 @@ public override AsyncUnaryCall AsyncUnaryCall( TRequest request, ClientInterceptorContext context, AsyncUnaryCallContinuation continuation) + { + return continuation(request, WithHeaders(context)); + } + + public override AsyncDuplexStreamingCall AsyncDuplexStreamingCall( + ClientInterceptorContext context, + AsyncDuplexStreamingCallContinuation continuation) + => continuation(WithHeaders(context)); + + private ClientInterceptorContext WithHeaders( + ClientInterceptorContext context) where TRequest : class where TResponse : class { var metadata = new Metadata(); if (context.Options.Headers is not null) @@ -35,10 +46,10 @@ public override AsyncUnaryCall AsyncUnaryCall( metadata.Add(_apiKeyHeaderName, _apiKey); var options = context.Options.WithHeaders(metadata); - return continuation(request, new ClientInterceptorContext( + return new ClientInterceptorContext( context.Method, context.Host, - options)); + options); } private static string NormalizeHeaderName(string? headerName) diff --git a/src/CSharpDB.Client/Internal/GrpcClientLease.cs b/src/CSharpDB.Client/Internal/GrpcClientLease.cs new file mode 100644 index 00000000..a58b2ee2 --- /dev/null +++ b/src/CSharpDB.Client/Internal/GrpcClientLease.cs @@ -0,0 +1,208 @@ +using CSharpDB.Client.Grpc; +using Grpc.Core; +using Grpc.Core.Interceptors; + +namespace CSharpDB.Client.Internal; + +/// Guards every RPC on one rental and resets its transaction state before returning the connection. +internal sealed class GrpcClientLease(GrpcTransportClient owner, Func release) : Interceptor +{ + private readonly object _gate = new(); + private HashSet? _transactions; + private HashSet? _rollbackAttempts; + private int _activeCalls; + private bool _healthy = true; + private TaskCompletionSource? _drained; + private Task? _disposeTask; + + public override AsyncUnaryCall AsyncUnaryCall( + TRequest request, ClientInterceptorContext context, + AsyncUnaryCallContinuation continuation) + { + string? transactionId = request switch + { + TransactionSqlRequest sql => sql.TransactionId, + TransactionIdRequest transaction => transaction.TransactionId, + _ => null, + }; + string method = context.Method.Name; + Begin(method, transactionId); + AsyncUnaryCall call; + try { call = continuation(request, context); } + catch { Complete(method, transactionId, null, failed: true); throw; } + return new AsyncUnaryCall( + ObserveAsync(call.ResponseAsync, method, transactionId), + static state => ((AsyncUnaryCall)state).ResponseHeadersAsync, + static state => ((AsyncUnaryCall)state).GetStatus(), + static state => ((AsyncUnaryCall)state).GetTrailers(), + static state => ((AsyncUnaryCall)state).Dispose(), call); + } + + internal Task ExecuteAsync( + GrpcCommandSession session, CommandSessionRequest command, CancellationToken ct) + where TResponse : class + { + string method = command.CommandCase switch + { + CommandSessionRequest.CommandOneofCase.GetInfo => "GetInfo", + CommandSessionRequest.CommandOneofCase.ExecuteSql => "ExecuteSql", + CommandSessionRequest.CommandOneofCase.BeginTransaction => "BeginTransaction", + CommandSessionRequest.CommandOneofCase.ExecuteInTransaction => "ExecuteInTransaction", + CommandSessionRequest.CommandOneofCase.CommitTransaction => "CommitTransaction", + CommandSessionRequest.CommandOneofCase.RollbackTransaction => "RollbackTransaction", + _ => throw new ArgumentException("The command is not supported by a client rental.", nameof(command)), + }; + string? transactionId = command.CommandCase switch + { + CommandSessionRequest.CommandOneofCase.ExecuteInTransaction => command.ExecuteInTransaction.TransactionId, + CommandSessionRequest.CommandOneofCase.CommitTransaction => command.CommitTransaction.TransactionId, + CommandSessionRequest.CommandOneofCase.RollbackTransaction => command.RollbackTransaction.TransactionId, + _ => null, + }; + Begin(method, transactionId); + Task response; + try { response = session.ExecuteAsync(command, ct); } + catch { Complete(method, transactionId, null, failed: true); throw; } + return ObserveAsync(response, method, transactionId); + } + + internal Task ExecuteSqlAsync( + GrpcCommandSession session, CommandSessionRequest command, CancellationToken ct) + { + string method = command.CommandCase switch + { + CommandSessionRequest.CommandOneofCase.ExecuteSql => "ExecuteSql", + CommandSessionRequest.CommandOneofCase.ExecuteInTransaction => "ExecuteInTransaction", + _ => throw new ArgumentException("The command must execute SQL.", nameof(command)), + }; + string? transactionId = command.ExecuteInTransaction?.TransactionId; + Begin(method, transactionId); + Task response; + try { response = session.ExecuteSqlAsync(command, ct); } + catch { Complete(method, transactionId, null, failed: true); throw; } + return ObserveSqlAsync(response, method, transactionId); + } + + private async Task ObserveSqlAsync( + Task response, string method, string? transactionId) + { + try + { + GrpcCommandSession.SqlReply result = await response.ConfigureAwait(false); + // SQL cannot change transaction ownership. Do not box the reply struct + // merely to run the common completion/drain accounting. + Complete(method, transactionId, null, failed: false); + return result; + } + catch { Complete(method, transactionId, null, failed: true); throw; } + } + + private void Begin(string method, string? transactionId) + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposeTask is not null, this); + if (transactionId is not null && _transactions?.Contains(transactionId) != true) + throw new InvalidOperationException("The transaction does not belong to this client rental."); + if (method == "RollbackTransaction" && + !(_rollbackAttempts ??= new(StringComparer.Ordinal)).Add(transactionId!)) + throw new InvalidOperationException("Rollback was already attempted. Dispose this client rental."); + _activeCalls++; + } + } + + private async Task ObserveAsync(Task response, string method, string? transactionId) + { + try + { + T result = await response.ConfigureAwait(false); + Complete(method, transactionId, result, failed: false); + return result; + } + catch { Complete(method, transactionId, null, failed: true); throw; } + } + + private void Complete(string method, string? transactionId, object? response, bool failed) + { + lock (_gate) + { + if (failed) _healthy = false; + else if (response is TransactionSessionInfoMessage transaction) + (_transactions ??= new(StringComparer.Ordinal)).Add(transaction.TransactionId); + else if (method is "CommitTransaction" or "RollbackTransaction") + { + _transactions?.Remove(transactionId!); + _rollbackAttempts?.Remove(transactionId!); + } + _activeCalls--; + if (_activeCalls == 0) _drained?.TrySetResult(); + } + } + + public ValueTask DisposeAsync() + { + Task drain; + TaskCompletionSource completion; + lock (_gate) + { + if (_disposeTask is not null) return new ValueTask(_disposeTask); + if (_activeCalls == 0 && (_transactions is null || _transactions.Count == 0)) + { + // Completed reads need no cleanup task. Publish disposal before + // returning the owner so a stale rental cannot issue another RPC. + _disposeTask = Task.CompletedTask; + try + { + ValueTask returned = release(owner, _healthy); + if (returned.IsCompletedSuccessfully) returned.GetAwaiter().GetResult(); + else _disposeTask = returned.AsTask(); + } + catch (Exception error) { _disposeTask = Task.FromException(error); } + return new ValueTask(_disposeTask); + } + completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + _disposeTask = completion.Task; + drain = _activeCalls == 0 ? Task.CompletedTask + : (_drained = new(TaskCreationOptions.RunContinuationsAsynchronously)).Task; + } + _ = ResetAndReturnAsync(drain, completion); + return new ValueTask(completion.Task); + } + + private async Task ResetAndReturnAsync(Task drain, TaskCompletionSource completion) + { + try + { + bool needsCleanup; + lock (_gate) needsCleanup = _activeCalls != 0 || _transactions?.Count > 0; + if (needsCleanup) + { + // Ordinary completed reads need no timer or cleanup RPC. + // One deadline covers outstanding calls and all rollback cleanup. + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try { await drain.WaitAsync(timeout.Token).ConfigureAwait(false); } + catch { lock (_gate) _healthy = false; } + if (drain.IsCompletedSuccessfully) + { + string[] pending; + lock (_gate) pending = _transactions is null ? [] + : _transactions.Except(_rollbackAttempts ?? []).ToArray(); + foreach (string transactionId in pending) + { + try { await owner.RollbackTransactionAsync(transactionId, timeout.Token).WaitAsync(timeout.Token).ConfigureAwait(false); } + catch { lock (_gate) _healthy = false; } + } + } + } + bool healthy; + lock (_gate) + { + healthy = _healthy; + _transactions?.Clear(); + } + await release(owner, healthy).ConfigureAwait(false); + completion.SetResult(); + } + catch (Exception error) { completion.SetException(error); } + } +} diff --git a/src/CSharpDB.Client/Internal/GrpcClientPool.cs b/src/CSharpDB.Client/Internal/GrpcClientPool.cs new file mode 100644 index 00000000..617f8bfd --- /dev/null +++ b/src/CSharpDB.Client/Internal/GrpcClientPool.cs @@ -0,0 +1,189 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace CSharpDB.Client.Internal; + +/// Pools validated gRPC clients; every checkout gets fresh logical session state. +internal static class GrpcClientPoolRegistry +{ + private static readonly ConcurrentDictionary Pools = new(); + // Client options and route contexts are immutable after initialization. Weak + // keys avoid retaining one-off configurations after their callers release them. + private static readonly ConditionalWeakTable ValidatedEndpoints = new(); + // Retire unused families as well as their sockets, including high-cardinality routes. + private static readonly Timer IdleTimer = new(static state => { _ = PruneAsync(); }, null, + TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30)); + + public static async ValueTask RentAsync( + CSharpDbClientOptions options, int maxPoolSize, CancellationToken ct) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maxPoolSize, 1); + var endpoint = ValidatedEndpoints.GetValue(options, ClientTransportResolver.ResolvePooledGrpcEndpoint); + var key = new GrpcPoolKey(options, endpoint, maxPoolSize); + while (true) + { + ct.ThrowIfCancellationRequested(); + var pool = Pools.GetOrAdd(key, static (poolKey, clientOptions) => + new GrpcClientPool(clientOptions, poolKey.MaxPoolSize), options); + var session = await pool.TryRentAsync(ct).ConfigureAwait(false); + if (session is not null) return session; + Pools.TryRemove(new KeyValuePair(key, pool)); + } + } + + public static async ValueTask ClearAsync(string? endpoint = null) + { + string? normalized = endpoint is null ? null : new Uri(endpoint, UriKind.Absolute).AbsoluteUri; + foreach (var pair in Pools) + if ((normalized is null || pair.Key.Endpoint == normalized) && + Pools.TryRemove(new KeyValuePair(pair.Key, pair.Value))) + await pair.Value.RetireAsync().ConfigureAwait(false); + } + + private static async Task PruneAsync() + { + foreach (var pair in Pools) + { + if (!pair.Value.TryRetireIdle(TimeSpan.FromMinutes(2))) continue; + Pools.TryRemove(new KeyValuePair(pair.Key, pair.Value)); + try { await pair.Value.RetireAsync().ConfigureAwait(false); } + catch { /* A background prune must not terminate the process. */ } + } + } + + private readonly struct GrpcPoolKey : IEquatable + { + public string Endpoint { get; } + public int MaxPoolSize { get; } + private readonly CSharpDbGrpcMode _mode; + private readonly CSharpDbGrpcCallMode _callMode; + private readonly HttpClient? _httpClient; + private readonly string? _keyspace, _routeKey, _apiKey; + private readonly string _apiKeyHeader; + + public GrpcPoolKey(CSharpDbClientOptions options, Uri endpoint, int maxPoolSize) + { + Endpoint = endpoint.AbsoluteUri; + MaxPoolSize = maxPoolSize; + _mode = options.GrpcMode; + _callMode = options.GrpcCallMode; + _httpClient = options.HttpClient; + _keyspace = options.RouteContext?.Keyspace; + _routeKey = options.RouteContext?.Key; + _apiKey = options.ApiKey; + _apiKeyHeader = options.ApiKeyHeaderName; + } + + public bool Equals(GrpcPoolKey other) + => Endpoint == other.Endpoint && MaxPoolSize == other.MaxPoolSize && + _mode == other._mode && _callMode == other._callMode && ReferenceEquals(_httpClient, other._httpClient) && + _keyspace == other._keyspace && _routeKey == other._routeKey && + _apiKey == other._apiKey && _apiKeyHeader == other._apiKeyHeader; + public override bool Equals(object? obj) => obj is GrpcPoolKey other && Equals(other); + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(Endpoint); hash.Add(MaxPoolSize); hash.Add(_mode); + hash.Add(_callMode); + hash.Add(_httpClient is null ? 0 : System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(_httpClient)); + hash.Add(_keyspace); hash.Add(_routeKey); hash.Add(_apiKey); hash.Add(_apiKeyHeader); + return hash.ToHashCode(); + } + } +} + +internal sealed class GrpcClientPool +{ + private readonly object _gate = new(); + private readonly Stack _idle = new(); + private readonly SemaphoreSlim _slots; + private readonly CancellationTokenSource _retirement = new(); + private readonly CSharpDbClientOptions _options; + private readonly Func _return; + private bool _retired; + private int _active; + private long _lastUse = Stopwatch.GetTimestamp(); + + public GrpcClientPool(CSharpDbClientOptions options, int maxPoolSize) + { + _options = options; + _slots = new SemaphoreSlim(maxPoolSize, maxPoolSize); + _return = ReturnAsync; + } + + public async ValueTask TryRentAsync(CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (!_slots.Wait(0)) + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, _retirement.Token); + try { await _slots.WaitAsync(linked.Token).ConfigureAwait(false); } + catch (OperationCanceledException) when (!ct.IsCancellationRequested && _retirement.IsCancellationRequested) + { return null; } + } + + GrpcTransportClient? client; + lock (_gate) + { + if (_retired) { _slots.Release(); return null; } + _active++; + _lastUse = Stopwatch.GetTimestamp(); + client = _idle.TryPop(out var idle) ? idle : null; + } + try + { + if (client is null) + { + client = (GrpcTransportClient)ClientTransportResolver.Create(_options); + await client.GetInfoAsync(ct).ConfigureAwait(false); + } + ct.ThrowIfCancellationRequested(); + return client.CreateLease(_return); + } + catch + { + try { if (client is not null) await client.DisposeAsync().ConfigureAwait(false); } + finally { lock (_gate) _active--; _slots.Release(); } + throw; + } + } + + private async ValueTask ReturnAsync(GrpcTransportClient client, bool reusable) + { + bool dispose; + lock (_gate) + { + _active--; + _lastUse = Stopwatch.GetTimestamp(); + dispose = _retired || !reusable; + if (!dispose) _idle.Push(client); + } + try { if (dispose) await client.DisposeAsync().ConfigureAwait(false); } + finally { _slots.Release(); } + } + + public bool TryRetireIdle(TimeSpan idleAge) + { + lock (_gate) + { + if (_retired || _active != 0 || Stopwatch.GetElapsedTime(_lastUse) < idleAge) return false; + _retired = true; + return true; + } + } + + public async ValueTask RetireAsync() + { + GrpcTransportClient[] idle; + lock (_gate) + { + _retired = true; + idle = _idle.ToArray(); + _idle.Clear(); + } + _retirement.Cancel(); + foreach (var client in idle) await client.DisposeAsync().ConfigureAwait(false); + // Active clients are disposed on return. Waiters reopen through the registry. + } +} diff --git a/src/CSharpDB.Client/Internal/GrpcCommandSession.cs b/src/CSharpDB.Client/Internal/GrpcCommandSession.cs new file mode 100644 index 00000000..fdc31989 --- /dev/null +++ b/src/CSharpDB.Client/Internal/GrpcCommandSession.cs @@ -0,0 +1,353 @@ +using CSharpDB.Client.Grpc; +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; +using Grpc.Core.Interceptors; + +namespace CSharpDB.Client.Internal; + +/// Adapts command calls to one ordered, versioned native gRPC stream. +internal sealed class GrpcCommandSession(CallInvoker invoker) : Interceptor, IAsyncDisposable +{ + private const string ResultSchemaCapability = "result-schema-ref-v1"; + private const string SingleInt64Capability = "single-int64-result-v1"; + private readonly object _gate = new(); + private readonly SemaphoreSlim _commands = new(1, 1); + private readonly CancellationTokenSource _lifetime = new(); + private readonly CSharpDbRpc.CSharpDbRpcClient _client = new(invoker); + private AsyncDuplexStreamingCall? _call; + private Exception? _fault; + private Task? _disposeTask; + private bool _negotiated; + private bool _resultSchemaReferences; + private bool _singleInt64Results; + private ulong _requestId; + private ulong _lastResultSchemaId; + private ResultSchema? _resultSchema; + + public override AsyncUnaryCall AsyncUnaryCall( + TRequest request, ClientInterceptorContext context, + AsyncUnaryCallContinuation continuation) + { + CommandSessionRequest? command = context.Method.Name switch + { + "GetInfo" => new() { GetInfo = (Empty)(object)request }, + "ExecuteSql" => new() { ExecuteSql = (SqlRequest)(object)request }, + "BeginTransaction" => new() { BeginTransaction = (Empty)(object)request }, + "ExecuteInTransaction" => new() { ExecuteInTransaction = (TransactionSqlRequest)(object)request }, + "CommitTransaction" => new() { CommitTransaction = (TransactionIdRequest)(object)request }, + "RollbackTransaction" => new() { RollbackTransaction = (TransactionIdRequest)(object)request }, + _ => null, + }; + if (command is null) return continuation(request, context); + var state = new UnaryCall(this, command, context.Options); + return new AsyncUnaryCall(state.Response, + static value => ((UnaryCall)value).HeadersAsync(), + static value => ((UnaryCall)value).Status(), + static value => ((UnaryCall)value).Trailers(), + static value => ((UnaryCall)value).Cancel(), state); + } + + internal async Task ExecuteAsync(CommandSessionRequest command, CancellationToken ct) + where TResponse : class + => ReadResponse((await ExchangeAsync(command, ct).ConfigureAwait(false)).Response); + + internal readonly record struct SqlReply(SqlExecutionResultMessage Response, GrpcSessionResultMetadata? Metadata); + private readonly record struct ExchangeReply(CommandSessionResponse Response, GrpcSessionResultMetadata? Metadata); + + internal async Task ExecuteSqlAsync(CommandSessionRequest command, CancellationToken ct) + { + if (command.CommandCase is not (CommandSessionRequest.CommandOneofCase.ExecuteSql or + CommandSessionRequest.CommandOneofCase.ExecuteInTransaction)) + throw new ArgumentException("The command must execute SQL.", nameof(command)); + ExchangeReply reply = await ExchangeAsync(command, ct, captureSqlMetadata: true).ConfigureAwait(false); + return new(ReadResponse(reply.Response), reply.Metadata); + } + + private static TResponse ReadResponse(CommandSessionResponse response) where TResponse : class + { + if (response.Error is not null) throw ReadError(response.Error); + return (TResponse)(object)(response.Info ?? (object?)response.Result ?? response.Transaction ?? (object?)response.Completed)!; + } + + private async Task ExchangeAsync(CommandSessionRequest command, CancellationToken ct, + bool captureSqlMetadata = false) + { + // A canceled waiter has sent nothing and must not cancel the active command. + await _commands.WaitAsync(ct).ConfigureAwait(false); + try + { + AsyncDuplexStreamingCall call; + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposeTask is not null, this); + if (_fault is not null) + throw new RpcException(new Status(StatusCode.Unavailable, "The command session is closed. Commands are never replayed.")); + call = _call ??= _client.CommandSession(cancellationToken: _lifetime.Token); + } + try + { + ct.ThrowIfCancellationRequested(); + if (!_negotiated) + { + await call.RequestStream.WriteAsync(new CommandSessionRequest + { + Hello = new CommandSessionHello + { + ProtocolVersion = 1, + Capabilities = { ResultSchemaCapability, SingleInt64Capability }, + }, + }, ct).ConfigureAwait(false); + if (!await call.ResponseStream.MoveNext(ct).ConfigureAwait(false)) + throw ProtocolError("The server closed the command session before negotiation."); + var hello = call.ResponseStream.Current; + if (hello.RequestId != 0 || hello.ResultSchemaId != 0 || hello.ResultSchemaReference || + hello.Ready is not { ProtocolVersion: 1, MaxInFlight: 1 } || + !hello.Ready.Capabilities.Contains("sql") || !hello.Ready.Capabilities.Contains("transactions")) + throw ProtocolError("The server did not negotiate the required command session capabilities."); + _resultSchemaReferences = hello.Ready.Capabilities.Contains(ResultSchemaCapability); + _singleInt64Results = hello.Ready.Capabilities.Contains(SingleInt64Capability); + _negotiated = true; + } + command.RequestId = checked(++_requestId); + await call.RequestStream.WriteAsync(command, ct).ConfigureAwait(false); + if (!await call.ResponseStream.MoveNext(ct).ConfigureAwait(false)) + throw ProtocolError("The server closed the command session before returning a command result."); + var response = call.ResponseStream.Current; + if (response.RequestId != command.RequestId || !Matches(command, response)) + throw ProtocolError("The command session returned an unexpected request ID or result type."); + if (response.Error is { } error) _ = ReadError(error); // Validate errors inside the fault boundary, too. + ResultSchema? schema = ResolveResultSchema(response); + ValidateSingleInt64Result(response, schema); + GrpcSessionResultMetadata? metadata = null; + if (captureSqlMetadata) + metadata = schema?.EnsureMetadata(); + else if (response.ResultSchemaReference) + schema!.Restore(response.Result!); + return new(response, metadata); + } + catch (Exception error) + { + _resultSchema = null; + lock (_gate) _fault ??= error; + _lifetime.Cancel(); + call.Dispose(); + throw; + } + } + finally { _commands.Release(); } + } + + private void ValidateSingleInt64Result(CommandSessionResponse response, ResultSchema? schema) + { + if (response.Result is not { HasScalarInt64: true } result) return; + if (!_singleInt64Results) + throw ProtocolError("The command session returned a compact integer result without negotiation."); + // Validate against the captured wire schema without rehydrating reference + // frames. A present Rows wrapper, even empty, conflicts with this encoding. + if (!result.IsQuery || result.Error is not null || result.Rows is not null || + !(schema?.HasSingleColumnMetadata ?? GrpcModelMapper.HasSingleColumnMetadata(result))) + throw ProtocolError("A compact integer result requires a successful one-column query and no rows payload."); + } + + private ResultSchema? ResolveResultSchema(CommandSessionResponse response) + { + if (!_resultSchemaReferences) + { + if (response.ResultSchemaId != 0 || response.ResultSchemaReference) + throw ProtocolError("The command session returned result schema fields without negotiation."); + return null; + } + + if (response.Result is not { IsQuery: true, Error: null } result) + { + if (response.ResultSchemaId != 0 || response.ResultSchemaReference) + throw ProtocolError("Only successful query results can define or reference a result schema."); + _resultSchema = null; + return null; + } + + if (response.ResultSchemaReference) + { + if (_resultSchema is null || response.ResultSchemaId == 0 || response.ResultSchemaId != _resultSchema.Id) + throw ProtocolError("The command session referenced an unknown or stale result schema."); + if (result.ColumnNames is not null || result.ColumnTypes is not null || + result.ColumnNullability is not null || result.Columns.Count != 0) + throw ProtocolError("A result schema reference must omit all column metadata."); + return _resultSchema; + } + + if (_lastResultSchemaId == ulong.MaxValue || response.ResultSchemaId != _lastResultSchemaId + 1) + throw ProtocolError("Result schema definitions must have consecutive nonzero IDs."); + _resultSchema = new ResultSchema(response.ResultSchemaId, result); + _lastResultSchemaId = response.ResultSchemaId; + return _resultSchema; + } + + /// Owns immutable private metadata, independent of every received result. + private sealed class ResultSchema + { + private readonly SqlExecutionResultMessage _definition; + private GrpcSessionResultMetadata? _metadata; + public ulong Id { get; } + public bool HasSingleColumnMetadata { get; } + + public ResultSchema(ulong id, SqlExecutionResultMessage result) + { + Id = id; + _definition = new SqlExecutionResultMessage + { + ColumnNames = result.ColumnNames?.Clone(), + ColumnTypes = result.ColumnTypes?.Clone(), + ColumnNullability = result.ColumnNullability?.Clone(), + }; + _definition.Columns.Add(result.Columns.Select(column => column.Clone())); + HasSingleColumnMetadata = GrpcModelMapper.HasSingleColumnMetadata(_definition); + } + + public GrpcSessionResultMetadata EnsureMetadata() + => _metadata ??= new GrpcSessionResultMetadata(_definition); + + public void Restore(SqlExecutionResultMessage result) + { + // Only the generic wire API restores protobuf metadata. Never expose + // private cache objects through its mutable returned message, including + // before the mapped metadata snapshot has first been initialized. + result.ColumnNames = _definition.ColumnNames?.Clone(); + result.ColumnTypes = _definition.ColumnTypes?.Clone(); + result.ColumnNullability = _definition.ColumnNullability?.Clone(); + result.Columns.Add(_definition.Columns.Select(column => column.Clone())); + } + } + + private static bool Matches(CommandSessionRequest command, CommandSessionResponse response) + => response.Error is not null || command.CommandCase switch + { + CommandSessionRequest.CommandOneofCase.GetInfo => response.Info is not null, + CommandSessionRequest.CommandOneofCase.ExecuteSql or CommandSessionRequest.CommandOneofCase.ExecuteInTransaction => response.Result is not null, + CommandSessionRequest.CommandOneofCase.BeginTransaction => response.Transaction is not null, + CommandSessionRequest.CommandOneofCase.CommitTransaction or CommandSessionRequest.CommandOneofCase.RollbackTransaction => response.Completed is not null, + _ => false, + }; + + private static RpcException ProtocolError(string message) + => new(new Status(StatusCode.DataLoss, message)); + + private static RpcException ReadError(CommandSessionError error) + { + var code = (StatusCode)error.StatusCode; + if (!System.Enum.IsDefined(code) || code == StatusCode.OK) throw ProtocolError("The command session returned an invalid error status."); + var trailers = new Metadata(); + try + { + foreach (var item in error.Trailers) + { + if (item.ValueCase == CommandSessionMetadata.ValueOneofCase.Binary) trailers.Add(item.Key, item.Binary.ToByteArray()); + else if (item.ValueCase == CommandSessionMetadata.ValueOneofCase.Text) trailers.Add(item.Key, item.Text); + else throw ProtocolError("The command session returned an invalid error trailer."); + } + } + catch (ArgumentException) { throw ProtocolError("The command session returned an invalid error trailer."); } + return new RpcException(new Status(code, error.Detail), trailers); + } + + public ValueTask DisposeAsync() + { + TaskCompletionSource completion; + lock (_gate) + { + if (_disposeTask is not null) return new ValueTask(_disposeTask); + completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + _disposeTask = completion.Task; + } + _ = CloseAsync(completion); + return new ValueTask(completion.Task); + } + + private async Task CloseAsync(TaskCompletionSource completion) + { + bool idle = _commands.Wait(0); + try + { + var call = _call; + if (idle && call is not null && _fault is null) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + try + { + await call.RequestStream.CompleteAsync().WaitAsync(timeout.Token).ConfigureAwait(false); + // EOF acknowledges server-side cleanup before the owner closes. + if (await call.ResponseStream.MoveNext(timeout.Token).ConfigureAwait(false)) + throw ProtocolError("An idle command session returned an unsolicited result."); + } + catch { /* Retirement still aborts a broken or unresponsive stream. */ } + } + } + finally + { + _lifetime.Cancel(); + _call?.Dispose(); + if (idle) _commands.Release(); + // A concurrent command may still be unwinding its cancellation. + // The semaphore and token source remain valid until it releases them. + completion.TrySetResult(); + } + } + + private sealed class UnaryCall where T : class + { + private readonly GrpcCommandSession _session; + private readonly CancellationTokenSource _cancellation; + public Task Response { get; } + + public UnaryCall(GrpcCommandSession session, CommandSessionRequest command, CallOptions options) + { + _session = session; + _cancellation = CancellationTokenSource.CreateLinkedTokenSource(options.CancellationToken); + if (options.Deadline is { } deadline) + { + TimeSpan remaining = deadline - DateTime.UtcNow; + if (remaining <= TimeSpan.Zero) _cancellation.Cancel(); + else _cancellation.CancelAfter(remaining); + } + Response = RunAsync(command); + } + + private async Task RunAsync(CommandSessionRequest command) + { + try + { + var response = await _session.ExchangeAsync(command, _cancellation.Token).ConfigureAwait(false); + return ReadResponse(response.Response); + } + finally { _cancellation.Dispose(); } + } + + public void Cancel() + { + try { _cancellation.Cancel(); } + catch (ObjectDisposedException) { } + } + + public async Task HeadersAsync() + { + try { await Response.ConfigureAwait(false); } + catch { } + return _session._call is { } call + ? await call.ResponseHeadersAsync.ConfigureAwait(false) : new Metadata(); + } + + public Status Status() + { + if (!Response.IsCompleted) throw new InvalidOperationException("The command has not completed."); + if (Response.IsCompletedSuccessfully) return global::Grpc.Core.Status.DefaultSuccess; + return Response.Exception?.GetBaseException() is RpcException rpc + ? rpc.Status : new Status(StatusCode.Cancelled, "The command was canceled."); + } + + public Metadata Trailers() + { + if (!Response.IsCompleted) throw new InvalidOperationException("The command has not completed."); + return Response.Exception?.GetBaseException() is RpcException rpc ? rpc.Trailers : new Metadata(); + } + } +} diff --git a/src/CSharpDB.Client/Internal/GrpcRouteContextClientInterceptor.cs b/src/CSharpDB.Client/Internal/GrpcRouteContextClientInterceptor.cs index 2e736156..99a98062 100644 --- a/src/CSharpDB.Client/Internal/GrpcRouteContextClientInterceptor.cs +++ b/src/CSharpDB.Client/Internal/GrpcRouteContextClientInterceptor.cs @@ -9,6 +9,17 @@ public override AsyncUnaryCall AsyncUnaryCall( TRequest request, ClientInterceptorContext context, AsyncUnaryCallContinuation continuation) + { + return continuation(request, WithHeaders(context)); + } + + public override AsyncDuplexStreamingCall AsyncDuplexStreamingCall( + ClientInterceptorContext context, + AsyncDuplexStreamingCallContinuation continuation) + => continuation(WithHeaders(context)); + + private ClientInterceptorContext WithHeaders( + ClientInterceptorContext context) where TRequest : class where TResponse : class { var metadata = new Metadata(); if (context.Options.Headers is not null) @@ -26,9 +37,9 @@ public override AsyncUnaryCall AsyncUnaryCall( metadata.Add(CSharpDbRouteHeaderNames.GrpcShardKey, routeContext.Key); var options = context.Options.WithHeaders(metadata); - return continuation(request, new ClientInterceptorContext( + return new ClientInterceptorContext( context.Method, context.Host, - options)); + options); } } diff --git a/src/CSharpDB.Client/Internal/GrpcTransportClient.ScalarProjection.cs b/src/CSharpDB.Client/Internal/GrpcTransportClient.ScalarProjection.cs new file mode 100644 index 00000000..119ed14a --- /dev/null +++ b/src/CSharpDB.Client/Internal/GrpcTransportClient.ScalarProjection.cs @@ -0,0 +1,63 @@ +using System.Runtime.CompilerServices; +using CSharpDB.Client.Grpc; +using Grpc.Core; + +namespace CSharpDB.Client.Internal; + +internal sealed partial class GrpcTransportClient : IScalarProjectionClient +{ + bool IScalarProjectionClient.SupportsScalarProjection => _commandSession is not null; + + ValueTask IScalarProjectionClient.ExecuteScalarProjectionAsync( + string sql, string? transactionId, CancellationToken cancellationToken) + { + if (_commandSession is null) + throw new NotSupportedException("Scalar projection requires a command session."); + + CommandSessionRequest command = transactionId is null + ? new() { ExecuteSql = new SqlRequest { Sql = sql } } + : new() + { + ExecuteInTransaction = new TransactionSqlRequest { TransactionId = transactionId, Sql = sql }, + }; + + // Dispatch through the same lease accounting, transaction checks and session fault boundary. + return MapScalarProjectionAsync(_lease is not null + ? _lease.ExecuteSqlAsync(_commandSession, command, cancellationToken) + : _commandSession.ExecuteSqlAsync(command, cancellationToken), + cancellationToken); + } + + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] + private static async ValueTask MapScalarProjectionAsync( + Task response, CancellationToken cancellationToken) + { + try + { + return MapScalarProjectionReply(await response.ConfigureAwait(false)); + } + catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled && cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(cancellationToken); + } + catch (RpcException ex) + { + throw TranslateRpcException(ex); + } + } + + internal static ScalarProjectionResult MapScalarProjectionReply(GrpcCommandSession.SqlReply reply) + { + SqlExecutionResultMessage response = reply.Response; + if (response.HasScalarInt64 && response.IsQuery && response.Error is null && response.Rows is null + && reply.Metadata is { } metadata && metadata.TryGetScalarType(out var declaredType)) + { + // Full SDK mapping validates duration before Data performs type conversion or cancellation. + _ = response.Elapsed.ToTimeSpan(); + return ScalarProjectionResult.FromCompactInt64(response.ScalarInt64, declaredType); + } + + // Map the response already received. Unsupported shapes must never execute SQL again. + return ScalarProjectionResult.FromFallback(GrpcModelMapper.ToSessionModel(response, reply.Metadata)); + } +} diff --git a/src/CSharpDB.Client/Internal/GrpcTransportClient.cs b/src/CSharpDB.Client/Internal/GrpcTransportClient.cs index acccc124..0ea56c10 100644 --- a/src/CSharpDB.Client/Internal/GrpcTransportClient.cs +++ b/src/CSharpDB.Client/Internal/GrpcTransportClient.cs @@ -19,13 +19,18 @@ internal sealed partial class GrpcTransportClient : ICSharpDbClient, ICSharpDbSh private readonly GrpcChannel _channel; private readonly CSharpDbRpc.CSharpDbRpcClient _client; private readonly string _endpoint; + private readonly CallInvoker _invoker; + private readonly GrpcClientLease? _lease; + private readonly GrpcCommandSession? _commandSession; public GrpcTransportClient( Uri endpoint, HttpClient? httpClient = null, string? apiKey = null, string? apiKeyHeaderName = null, - CSharpDbRouteContext? routeContext = null) + CSharpDbRouteContext? routeContext = null, + CSharpDbGrpcMode grpcMode = CSharpDbGrpcMode.Auto, + CSharpDbGrpcCallMode grpcCallMode = CSharpDbGrpcCallMode.Unary) { ArgumentNullException.ThrowIfNull(endpoint); @@ -37,15 +42,24 @@ public GrpcTransportClient( channelOptions.HttpClient = httpClient; channelOptions.DisposeHttpClient = false; } - else if (endpoint.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)) + else if (grpcMode == CSharpDbGrpcMode.Auto && + endpoint.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)) { channelOptions.HttpHandler = new GrpcWebHandler( GrpcWebMode.GrpcWeb, new HttpClientHandler()); + // This handler belongs to the transport, including when a pool retires it. + channelOptions.DisposeHttpClient = true; channelOptions.HttpVersion = System.Net.HttpVersion.Version11; channelOptions.HttpVersionPolicy = HttpVersionPolicy.RequestVersionExact; } + if (grpcMode == CSharpDbGrpcMode.Native) + { + channelOptions.HttpVersion = System.Net.HttpVersion.Version20; + channelOptions.HttpVersionPolicy = HttpVersionPolicy.RequestVersionExact; + } + _channel = GrpcChannel.ForAddress(endpoint, channelOptions); CallInvoker invoker = _channel.CreateCallInvoker(); if (!string.IsNullOrWhiteSpace(apiKey)) @@ -53,9 +67,29 @@ public GrpcTransportClient( if (routeContext is not null) invoker = invoker.Intercept(new GrpcRouteContextClientInterceptor(routeContext)); + if (grpcCallMode == CSharpDbGrpcCallMode.Streaming) + { + _commandSession = new GrpcCommandSession(invoker); + invoker = invoker.Intercept(_commandSession); + } + + _invoker = invoker; _client = new CSharpDbRpc.CSharpDbRpcClient(invoker); } + private GrpcTransportClient(GrpcTransportClient owner, GrpcClientLease lease) + { + _channel = owner._channel; + _endpoint = owner._endpoint; + _lease = lease; + _commandSession = owner._commandSession; + _invoker = owner._invoker.Intercept(lease); + _client = new CSharpDbRpc.CSharpDbRpcClient(_invoker); + } + + internal GrpcTransportClient CreateLease(Func release) + => new(this, new GrpcClientLease(this, release)); + public string DataSource => _endpoint; public Task GetShardMapAsync(CancellationToken ct = default) @@ -219,7 +253,9 @@ public Task MarkDirectoryEntryStaleAsync( ct); public Task GetInfoAsync(CancellationToken ct = default) - => CallAsync(_client.GetInfoAsync(EmptyRequest, cancellationToken: ct), GrpcModelMapper.ToModel, ct); + => _commandSession is not null + ? CallSessionAsync(new() { GetInfo = EmptyRequest }, GrpcModelMapper.ToModel, ct) + : CallAsync(_client.GetInfoAsync(EmptyRequest, cancellationToken: ct), GrpcModelMapper.ToModel, ct); public Task> GetTableNamesAsync(CancellationToken ct = default) => CallAsync(_client.GetTableNamesAsync(EmptyRequest, cancellationToken: ct), GrpcModelMapper.ToStringList, ct); @@ -462,23 +498,36 @@ public Task ExecuteProcedureAsync(string name, IReadOn }, cancellationToken: ct), GrpcModelMapper.ToModel, ct); public Task ExecuteSqlAsync(string sql, CancellationToken ct = default) - => CallAsync(_client.ExecuteSqlAsync(new SqlRequest { Sql = sql }, cancellationToken: ct), GrpcModelMapper.ToModel, ct); + => _commandSession is not null + ? CallSessionSqlAsync(new() { ExecuteSql = new SqlRequest { Sql = sql } }, ct) + : CallAsync(_client.ExecuteSqlAsync(new SqlRequest { Sql = sql }, cancellationToken: ct), GrpcModelMapper.ToModel, ct); public Task BeginTransactionAsync(CancellationToken ct = default) - => CallAsync(_client.BeginTransactionAsync(EmptyRequest, cancellationToken: ct), GrpcModelMapper.ToModel, ct); + => _commandSession is not null + ? CallSessionAsync(new() { BeginTransaction = EmptyRequest }, GrpcModelMapper.ToModel, ct) + : CallAsync(_client.BeginTransactionAsync(EmptyRequest, cancellationToken: ct), GrpcModelMapper.ToModel, ct); public Task ExecuteInTransactionAsync(string transactionId, string sql, CancellationToken ct = default) - => CallAsync(_client.ExecuteInTransactionAsync(new TransactionSqlRequest - { - TransactionId = transactionId, - Sql = sql, - }, cancellationToken: ct), GrpcModelMapper.ToModel, ct); + => _commandSession is not null + ? CallSessionSqlAsync(new() + { + ExecuteInTransaction = new TransactionSqlRequest { TransactionId = transactionId, Sql = sql }, + }, ct) + : CallAsync(_client.ExecuteInTransactionAsync(new TransactionSqlRequest + { + TransactionId = transactionId, + Sql = sql, + }, cancellationToken: ct), GrpcModelMapper.ToModel, ct); public Task CommitTransactionAsync(string transactionId, CancellationToken ct = default) - => CallEmptyAsync(_client.CommitTransactionAsync(new TransactionIdRequest { TransactionId = transactionId }, cancellationToken: ct), ct); + => _commandSession is not null + ? CallSessionEmptyAsync(new() { CommitTransaction = new TransactionIdRequest { TransactionId = transactionId } }, ct) + : CallEmptyAsync(_client.CommitTransactionAsync(new TransactionIdRequest { TransactionId = transactionId }, cancellationToken: ct), ct); public Task RollbackTransactionAsync(string transactionId, CancellationToken ct = default) - => CallEmptyAsync(_client.RollbackTransactionAsync(new TransactionIdRequest { TransactionId = transactionId }, cancellationToken: ct), ct); + => _commandSession is not null + ? CallSessionEmptyAsync(new() { RollbackTransaction = new TransactionIdRequest { TransactionId = transactionId } }, ct) + : CallEmptyAsync(_client.RollbackTransactionAsync(new TransactionIdRequest { TransactionId = transactionId }, cancellationToken: ct), ct); public Task> GetCollectionNamesAsync(CancellationToken ct = default) => CallAsync(_client.GetCollectionNamesAsync(EmptyRequest, cancellationToken: ct), GrpcModelMapper.ToStringList, ct); @@ -575,10 +624,57 @@ public Task CheckIndexesAsync(string? databasePath = null, s public ValueTask DisposeAsync() { + if (_lease is not null) return _lease.DisposeAsync(); + if (_commandSession is not null) return DisposeCommandSessionAsync(); _channel.Dispose(); return ValueTask.CompletedTask; } + private async ValueTask DisposeCommandSessionAsync() + { + try { await _commandSession!.DisposeAsync().ConfigureAwait(false); } + finally { _channel.Dispose(); } + } + + // Rental guards run synchronously, before asynchronous response mapping, + // just as they do on the generated unary call path. + private Task DispatchSessionAsync(CommandSessionRequest command, CancellationToken ct) + where TResponse : class + => _lease is not null + ? _lease.ExecuteAsync(_commandSession!, command, ct) + : _commandSession!.ExecuteAsync(command, ct); + + private Task CallSessionAsync( + CommandSessionRequest command, Func map, CancellationToken ct) + where TResponse : class + => MapSessionAsync(DispatchSessionAsync(command, ct), map, ct); + + private Task CallSessionEmptyAsync(CommandSessionRequest command, CancellationToken ct) + => MapSessionAsync(DispatchSessionAsync(command, ct), static response => response, ct); + + private Task CallSessionSqlAsync(CommandSessionRequest command, CancellationToken ct) + => MapSessionAsync(_lease is not null + ? _lease.ExecuteSqlAsync(_commandSession!, command, ct) + : _commandSession!.ExecuteSqlAsync(command, ct), + static reply => GrpcModelMapper.ToSessionModel(reply.Response, reply.Metadata), ct); + + private static async Task MapSessionAsync( + Task response, Func map, CancellationToken ct) + { + try + { + return map(await response.ConfigureAwait(false)); + } + catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled && ct.IsCancellationRequested) + { + throw new OperationCanceledException(ct); + } + catch (RpcException ex) + { + throw TranslateRpcException(ex); + } + } + private async Task CallEmptyAsync(AsyncUnaryCall call, CancellationToken ct) { try diff --git a/src/CSharpDB.Client/Internal/IScalarProjectionClient.cs b/src/CSharpDB.Client/Internal/IScalarProjectionClient.cs new file mode 100644 index 00000000..1602dbc4 --- /dev/null +++ b/src/CSharpDB.Client/Internal/IScalarProjectionClient.cs @@ -0,0 +1,55 @@ +using CSharpDB.Client.Models; + +namespace CSharpDB.Client.Internal; + +/// A private result projection over the existing client session and rental. +internal interface IScalarProjectionClient +{ + bool SupportsScalarProjection { get; } + /// Each returned ValueTask must be consumed exactly once. + ValueTask ExecuteScalarProjectionAsync( + string sql, string? transactionId, CancellationToken cancellationToken); +} + +// Only immutable primitives cross this boundary; canonical SDK metadata remains private. +internal sealed record class ScalarTypeSnapshot +{ + public ScalarTypeSnapshot( + SqlTypeKind Kind, int? Length, int? Precision, int? Scale, int? FractionalSecondsPrecision) + { + this.Kind = Kind; + this.Length = Length; + this.Precision = Precision; + this.Scale = Scale; + this.FractionalSecondsPrecision = FractionalSecondsPrecision; + } + + public SqlTypeKind Kind { get; } + public int? Length { get; } + public int? Precision { get; } + public int? Scale { get; } + public int? FractionalSecondsPrecision { get; } +} + +internal readonly struct ScalarProjectionResult +{ + private ScalarProjectionResult( + bool hasCompactInt64, long value, ScalarTypeSnapshot? declaredType, SqlExecutionResult? fallback) + { + HasCompactInt64 = hasCompactInt64; + Int64 = value; + DeclaredType = declaredType; + Fallback = fallback; + } + + internal bool HasCompactInt64 { get; } + internal long Int64 { get; } + internal ScalarTypeSnapshot? DeclaredType { get; } + internal SqlExecutionResult? Fallback { get; } + + internal static ScalarProjectionResult FromCompactInt64(long value, ScalarTypeSnapshot? declaredType) + => new(true, value, declaredType, null); + + internal static ScalarProjectionResult FromFallback(SqlExecutionResult result) + => new(false, default, null, result ?? throw new ArgumentNullException(nameof(result))); +} diff --git a/src/CSharpDB.Client/Protos/csharpdb_rpc.proto b/src/CSharpDB.Client/Protos/csharpdb_rpc.proto index b3059a93..c3545c8a 100644 --- a/src/CSharpDB.Client/Protos/csharpdb_rpc.proto +++ b/src/CSharpDB.Client/Protos/csharpdb_rpc.proto @@ -82,6 +82,7 @@ service CSharpDbRpc { rpc ExecuteProcedure (ExecuteProcedureRequest) returns (ProcedureExecutionResultMessage); rpc ExecuteSql (SqlRequest) returns (SqlExecutionResultMessage); + rpc CommandSession (stream CommandSessionRequest) returns (stream CommandSessionResponse); rpc BeginTransaction (google.protobuf.Empty) returns (TransactionSessionInfoMessage); rpc ExecuteInTransaction (TransactionSqlRequest) returns (SqlExecutionResultMessage); @@ -780,6 +781,60 @@ message ExecuteProcedureRequest { VariantObject args = 2; } +// Version 1 deliberately permits only one command in flight per stream. +message CommandSessionHello { + uint32 protocol_version = 1; + repeated string capabilities = 2; +} + +message CommandSessionReady { + uint32 protocol_version = 1; + uint32 max_in_flight = 2; + repeated string capabilities = 3; +} + +message CommandSessionRequest { + uint64 request_id = 1; + oneof command { + CommandSessionHello hello = 2; + google.protobuf.Empty get_info = 3; + SqlRequest execute_sql = 4; + google.protobuf.Empty begin_transaction = 5; + TransactionSqlRequest execute_in_transaction = 6; + TransactionIdRequest commit_transaction = 7; + TransactionIdRequest rollback_transaction = 8; + } +} + +message CommandSessionMetadata { + string key = 1; + oneof value { + string text = 2; + bytes binary = 3; + } +} + +message CommandSessionError { + int32 status_code = 1; + string detail = 2; + repeated CommandSessionMetadata trailers = 3; +} + +message CommandSessionResponse { + uint64 request_id = 1; + oneof outcome { + CommandSessionReady ready = 2; + DatabaseInfoMessage info = 3; + SqlExecutionResultMessage result = 4; + TransactionSessionInfoMessage transaction = 5; + google.protobuf.Empty completed = 6; + CommandSessionError error = 7; + } + // Used only after both peers negotiate result-schema-ref-v1. + uint64 result_schema_id = 8; + bool result_schema_reference = 9; +} + message SqlRequest { string sql = 1; } @@ -1024,6 +1079,8 @@ message SqlExecutionResultMessage { StringList column_types = 7; BoolList column_nullability = 8; repeated ColumnDefinitionMessage columns = 9; + // Negotiated single-int64-result-v1 command sessions only. Presence includes zero. + optional sint64 scalar_int64 = 10; } message DatabaseInfoMessage { diff --git a/src/CSharpDB.Client/README.md b/src/CSharpDB.Client/README.md index 51e84a30..e4f7ed28 100644 --- a/src/CSharpDB.Client/README.md +++ b/src/CSharpDB.Client/README.md @@ -4,11 +4,55 @@ It owns the public client contract used to talk to a database, while transport and lower-level implementation details stay behind that boundary. +## Shared gRPC pooling + +Rent a client when your application creates and disposes a client for each +operation. The SDK owns the pool; ADO.NET's `Pooling=true` uses this same pool. + +```csharp +var options = new CSharpDbClientOptions +{ + Transport = CSharpDbTransport.Grpc, + Endpoint = "http://localhost:5000", +}; +await using var client = await CSharpDbClient.RentAsync(options, maxPoolSize: 32, ct: ct); +var result = await client.ExecuteSqlAsync("SELECT 42", ct); +// Dispose returns the connection to the shared pool. +``` + +`Create(options)` remains an unpooled factory. Keeping one created client open +already reuses its channel. `RentAsync` adds reuse across separate client +lifetimes and supports both Auto gRPC-Web and Native HTTP/2. Pools isolate +endpoint, mode, credentials, routing, caller-owned HTTP client, and capacity. +The same configuration and capacity allow SDK and ADO.NET to share a pool. + +A new physical connection performs `GetInfo`; warm rentals make no validation +RPC. At capacity, rentals wait and honor cancellation. Each rental has fresh +lifetime and transaction ownership: disposed rentals cannot send RPCs, and a +transaction identifier must belong to the rental that created it. Dispose +drains outstanding RPCs and rolls back unfinished transactions under one +five-second cleanup deadline. Failed or canceled RPCs discard the connection; +SQL error responses do not automatically discard it. Commands are never +replayed. An unacknowledged rollback does not prove that the server released +its transaction. + +`ClearPoolAsync(endpoint)` retires every configuration for an endpoint; +`ClearAllPoolsAsync()` retires all shared gRPC pools. Idle connections close +immediately, active connections close on return, and waiting rentals can join +a new pool. Families without active rentals expire after two idle minutes, +checked every 30 seconds. Caller-owned `HttpClient` objects remain caller-owned. +API-key authentication still occurs per RPC. Warm rentals do not prove current +server reachability, and remote prepared statement handles are not added. + ## Current Direction - `CSharpDB.Client` is now the real implementation layer for database access. - `Direct`, `Http`, and `Grpc` are implemented transports today. -- `NamedPipes` remains the only future transport target. +- `NamedPipes` is reserved but remains unimplemented. +- The shared pooling/session foundation is retained after the gRPC + micro-optimization experiment. A custom TCP transport is a separate proposed + experiment and is not implemented in this checkpoint. See the + [network experiment closeout](../../tests/CSharpDB.NetworkBenchmarks/CLOSEOUT.md). ## Logical Types, Carriers, and Row Values @@ -57,6 +101,17 @@ var client = CSharpDbClient.Create(new CSharpDbClientOptions The transport can be selected explicitly with `Transport`. If it is omitted, the client infers it from `Endpoint` and otherwise defaults to direct. +Transactions opened by one direct client, including the daemon's database client, +share its cached database and writer coordinator. Each session owns an isolated +transaction and temporary-table scope. Commit, rollback, and rental cleanup finish +the session without closing another session's database or WAL. Conflicting writes +return `TransactionConflict`; commands are not replayed automatically. Releasing +the cached database or starting exclusive maintenance requires all transactions +and their finalization to finish. File and incremental hybrid owners support +overlapping transactions. Snapshot hybrid mode with automatic commit or disposal +persistence requires exclusive session access and returns `Busy` for overlapping +transactions or ordinary commands until the active transaction finishes. + Direct resolution currently accepts: - `Endpoint` as a file path @@ -181,6 +236,86 @@ var client = CSharpDbClient.Create(new CSharpDbClientOptions This resolves to the dedicated `CSharpDB.Daemon` gRPC host. +`GrpcMode = CSharpDbGrpcMode.Auto` preserves existing behavior: plain HTTP +uses gRPC-Web over HTTP/1.1 when no `HttpClient` is supplied; HTTPS uses native +gRPC. A supplied `HttpClient` retains its existing handler configuration. + +To require native HTTP/2 gRPC explicitly, including on an unencrypted local +HTTP/2-only listener, use: + +```csharp +await using var client = CSharpDbClient.Create(new CSharpDbClientOptions +{ + Transport = CSharpDbTransport.Grpc, + Endpoint = "http://127.0.0.1:5821", + GrpcMode = CSharpDbGrpcMode.Native +}); +``` + +Native mode requires HTTP/2 and does not fall back to gRPC-Web or HTTP/1.1. +Configure the corresponding daemon listener as shown in its +[transport guide](../CSharpDB.Daemon/README.md#native-http2-grpc). +Reuse this client across operations to reuse the underlying connection. +Custom `HttpClient` instances remain caller-owned and must support HTTP/2 +without a gRPC-Web wrapper when used with native mode. + +### Experimental persistent command sessions + +Set both `GrpcMode = CSharpDbGrpcMode.Native` and +`GrpcCallMode = CSharpDbGrpcCallMode.Streaming` to send SQL, transaction +commands, and `GetInfo` over one persistent bidirectional gRPC call: + +```csharp +var options = new CSharpDbClientOptions +{ + Transport = CSharpDbTransport.Grpc, + Endpoint = "http://127.0.0.1:5821", + GrpcMode = CSharpDbGrpcMode.Native, + GrpcCallMode = CSharpDbGrpcCallMode.Streaming +}; +await using var client = await CSharpDbClient.RentAsync(options, maxPoolSize: 32); +await client.ExecuteSqlAsync("SELECT 42"); +``` + +The first command negotiates protocol version 1 and SQL/transaction +capabilities. Authentication and routing metadata bind to the stream when +it opens. Each physical stream executes one command at a time; concurrent +calls queue. SDK and ADO.NET share the existing pool for matching options, +including call mode. `Unary` remains the default and uses a separate pool +family. Other APIs continue using their existing unary RPCs. + +Peers can also negotiate `result-schema-ref-v1`. Each stream retains one +private result schema and sends a schema reference when the next query's +actual metadata matches it. A changed schema sends full metadata again; +non-query results, errors, and other commands clear the cached schema. +Older peers continue sending full metadata. Public result metadata remains +independent across calls and rentals. An invalid schema reference closes +the stream without replaying the command. + +The client maps each negotiated schema definition once and privately caches +that mapping. Referenced results use the captured mapping directly, with +fresh arrays, column objects, and declared-type objects for every result. + +The independent `single-int64-result-v1` capability encodes a successful +one-row, one-column `Int64` result without nested protobuf row objects. +Selection uses the actual result shape and carrier; declared SQL types and +public result collections are preserved. Nulls, other carriers, and other +result shapes use the existing encoding. Peers negotiate this capability +before use, and unary RPCs continue using the existing encoding. + +Returning a rental rolls back its unfinished transactions before reuse. +Disconnecting a stream also triggers server rollback of its remaining +transactions, with a five-second cleanup deadline. Cancellation during an +active exchange closes the stream; a canceled queue waiter sends nothing. +Failed rentals are discarded. Commands are never automatically replayed, +and a lost commit response has an unknown outcome until the application +checks the data. An older server without `CommandSession` fails explicitly. + +This prototype has no pipelining, batching, remote prepared handles, or +session-wide temporary tables outside transaction sessions. Benchmark it +against unary mode for your workload before enabling it; see the +[fixed socket experiment](../../tests/CSharpDB.NetworkBenchmarks/README.md#persistent-command-session-experiment). + When the remote host is configured with API-key mode, set `ApiKey` once on the client options. The HTTP transport sends it as a request header, and the gRPC transport sends it as call metadata. diff --git a/src/CSharpDB.Daemon/Grpc/CSharpDbRpcService.Session.cs b/src/CSharpDB.Daemon/Grpc/CSharpDbRpcService.Session.cs new file mode 100644 index 00000000..eeaed2ee --- /dev/null +++ b/src/CSharpDB.Daemon/Grpc/CSharpDbRpcService.Session.cs @@ -0,0 +1,132 @@ +using CSharpDB.Client.Grpc; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace CSharpDB.Daemon.Grpc; + +public sealed partial class CSharpDbRpcService +{ + public override async Task CommandSession( + IAsyncStreamReader requestStream, + IServerStreamWriter responseStream, + ServerCallContext context) + { + if (context.GetHttpContext().Request.Protocol != "HTTP/2") + throw new RpcException(new Status(StatusCode.Unimplemented, "Command sessions require native HTTP/2.")); + + var transactions = new HashSet(StringComparer.Ordinal); + try + { + using (var handshake = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken)) + { + handshake.CancelAfter(TimeSpan.FromSeconds(10)); + if (!await requestStream.MoveNext(handshake.Token).ConfigureAwait(false)) return; + } + CommandSessionRequest hello = requestStream.Current; + if (hello.RequestId != 0 || hello.CommandCase != CommandSessionRequest.CommandOneofCase.Hello || + hello.Hello.ProtocolVersion != 1) + throw new RpcException(new Status(StatusCode.Unimplemented, "Command session protocol version 1 is required.")); + + var ready = new CommandSessionReady { ProtocolVersion = 1, MaxInFlight = 1 }; + ready.Capabilities.Add(["sql", "transactions"]); + bool reuseResultSchema = hello.Hello.Capabilities.Contains(CommandResultSchemaCache.Capability); + CommandResultSchemaCache? resultSchemas = null; + if (reuseResultSchema) + { + ready.Capabilities.Add(CommandResultSchemaCache.Capability); + resultSchemas = new CommandResultSchemaCache(); + } + bool compactScalarInt64 = hello.Hello.Capabilities.Contains(SingleInt64ResultCapability); + if (compactScalarInt64) ready.Capabilities.Add(SingleInt64ResultCapability); + await responseStream.WriteAsync(new CommandSessionResponse { Ready = ready }).ConfigureAwait(false); + ulong lastRequest = 0; + while (await requestStream.MoveNext(context.CancellationToken).ConfigureAwait(false)) + { + CommandSessionRequest request = requestStream.Current; + if (request.RequestId != checked(lastRequest + 1) || + request.CommandCase is CommandSessionRequest.CommandOneofCase.None or CommandSessionRequest.CommandOneofCase.Hello) + throw new RpcException(new Status(StatusCode.InvalidArgument, "Command IDs must be consecutive and commands must be supported.")); + lastRequest = request.RequestId; + var response = new CommandSessionResponse { RequestId = request.RequestId }; + try + { + switch (request.CommandCase) + { + case CommandSessionRequest.CommandOneofCase.GetInfo: + response.Info = await GetInfo(EmptyResponse, context).ConfigureAwait(false); + break; + case CommandSessionRequest.CommandOneofCase.ExecuteSql: + var sqlResult = await ExecuteSqlResultAsync(request.ExecuteSql, context, static result => result).ConfigureAwait(false); + SetCommandSessionResult(response, sqlResult, resultSchemas, compactScalarInt64); + break; + case CommandSessionRequest.CommandOneofCase.BeginTransaction: + if (transactions.Count >= 64) + throw new RpcException(new Status(StatusCode.ResourceExhausted, "A command session supports at most 64 active transactions.")); + response.Transaction = await BeginTransaction(EmptyResponse, context).ConfigureAwait(false); + transactions.Add(response.Transaction.TransactionId); + break; + case CommandSessionRequest.CommandOneofCase.ExecuteInTransaction: + RequireSessionTransaction(transactions, request.ExecuteInTransaction.TransactionId); + var transactionResult = await ExecuteAsync(context, + ct => client.ExecuteInTransactionAsync(request.ExecuteInTransaction.TransactionId, + request.ExecuteInTransaction.Sql, ct), static result => result).ConfigureAwait(false); + SetCommandSessionResult(response, transactionResult, resultSchemas, compactScalarInt64); + break; + case CommandSessionRequest.CommandOneofCase.CommitTransaction: + RequireSessionTransaction(transactions, request.CommitTransaction.TransactionId); + response.Completed = await CommitTransaction(request.CommitTransaction, context).ConfigureAwait(false); + transactions.Remove(request.CommitTransaction.TransactionId); + break; + case CommandSessionRequest.CommandOneofCase.RollbackTransaction: + RequireSessionTransaction(transactions, request.RollbackTransaction.TransactionId); + response.Completed = await RollbackTransaction(request.RollbackTransaction, context).ConfigureAwait(false); + transactions.Remove(request.RollbackTransaction.TransactionId); + break; + default: + throw new RpcException(new Status(StatusCode.Unimplemented, "Unsupported session command.")); + } + } + catch (OperationCanceledException) when (context.CancellationToken.IsCancellationRequested) { throw; } + catch (Exception error) + { + RpcException rpc = error as RpcException ?? TranslateException(error); + response.Error = new CommandSessionError { StatusCode = (int)rpc.StatusCode, Detail = rpc.Status.Detail }; + foreach (var trailer in rpc.Trailers) + { + var value = new CommandSessionMetadata { Key = trailer.Key }; + if (trailer.IsBinary) value.Binary = ByteString.CopyFrom(trailer.ValueBytes); + else value.Text = trailer.Value; + response.Error.Trailers.Add(value); + } + } + if (response.Result is not { IsQuery: true, Error: null }) resultSchemas?.Clear(); + await responseStream.WriteAsync(response).ConfigureAwait(false); + } + } + finally + { + // Even a Begin response lost to disconnect leaves its transaction + // owned here. Never close the shared database or replay a command. + using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + foreach (string transaction in transactions) + { + try { await client.RollbackTransactionAsync(transaction, cleanup.Token).ConfigureAwait(false); } + catch (Exception error) + { + // Cleanup is best effort; surface failures without masking the original call error. + services?.GetService>()? + .LogWarning(error, "Command session transaction cleanup failed."); + } + } + } + } + + private static void RequireSessionTransaction(HashSet transactions, string transaction) + { + if (!transactions.Contains(transaction)) + throw new RpcException(new Status(StatusCode.PermissionDenied, "The transaction does not belong to this command session.")); + } +} diff --git a/src/CSharpDB.Daemon/Grpc/CSharpDbRpcService.SessionSchema.cs b/src/CSharpDB.Daemon/Grpc/CSharpDbRpcService.SessionSchema.cs new file mode 100644 index 00000000..9f8cb024 --- /dev/null +++ b/src/CSharpDB.Daemon/Grpc/CSharpDbRpcService.SessionSchema.cs @@ -0,0 +1,128 @@ +using CSharpDB.Client.Grpc; +using CSharpDB.Client.Models; + +namespace CSharpDB.Daemon.Grpc; + +public sealed partial class CSharpDbRpcService +{ + private const string SingleInt64ResultCapability = "single-int64-result-v1"; + + private static void SetCommandSessionResult(CommandSessionResponse response, SqlExecutionResult result, + CommandResultSchemaCache? schemas, bool allowCompactScalarInt64) + { + if (schemas is null) response.Result = GrpcModelMapper.ToMessage(result, includeMetadata: true, allowCompactScalarInt64: allowCompactScalarInt64); + else schemas.SetResult(response, result, allowCompactScalarInt64); + } + + /// One privately owned schema snapshot for one negotiated command stream. + private sealed class CommandResultSchemaCache + { + internal const string Capability = "result-schema-ref-v1"; + private ResultSchemaSnapshot? _schema; + private ulong _schemaId; + + internal void Clear() => _schema = null; + + internal void SetResult(CommandSessionResponse response, SqlExecutionResult result, bool allowCompactScalarInt64) + { + if (!result.IsQuery || result.Error is not null) + { + Clear(); + response.Result = GrpcModelMapper.ToMessage(result, includeMetadata: true, allowCompactScalarInt64: allowCompactScalarInt64); + return; + } + if (_schema?.Matches(result) == true) + { + // Compare fresh backend metadata first; a hit never builds or + // serializes metadata messages merely to discard them afterward. + response.Result = GrpcModelMapper.ToMessage(result, includeMetadata: false, allowCompactScalarInt64: allowCompactScalarInt64); + response.ResultSchemaId = _schemaId; + response.ResultSchemaReference = true; + return; + } + + SqlExecutionResultMessage message = GrpcModelMapper.ToMessage(result, includeMetadata: true, allowCompactScalarInt64: allowCompactScalarInt64); + var schema = new ResultSchemaSnapshot(result); + ulong id = checked(_schemaId + 1); + response.Result = message; + response.ResultSchemaId = id; + _schema = schema; + _schemaId = id; + } + } + + private sealed class ResultSchemaSnapshot + { + private readonly string[]? _names; + private readonly string[]? _types; + private readonly bool[]? _nullability; + private readonly ColumnDefinition[]? _columns; + + internal ResultSchemaSnapshot(SqlExecutionResult result) + { + _names = result.ColumnNames is null ? null : (string[])result.ColumnNames.Clone(); + _types = result.ColumnTypes is null ? null : (string[])result.ColumnTypes.Clone(); + _nullability = result.ColumnNullability is null ? null : (bool[])result.ColumnNullability.Clone(); + if (result.Columns is { } columns) + { + _columns = new ColumnDefinition[columns.Length]; + for (int i = 0; i < columns.Length; i++) _columns[i] = CopyColumn(columns[i]); + } + } + + internal bool Matches(SqlExecutionResult result) + { + if (!EqualValues(_names, result.ColumnNames) || !EqualValues(_types, result.ColumnTypes) + || !EqualValues(_nullability, result.ColumnNullability)) return false; + var columns = result.Columns; + if (_columns is null || columns is null) return _columns is null && columns is null; + if (_columns.Length != columns.Length) return false; + for (int i = 0; i < columns.Length; i++) + if (!EqualColumn(_columns[i], columns[i])) return false; + return true; + } + + private static bool EqualValues(T[]? expected, T[]? actual) + { + if (expected is null || actual is null) return expected is null && actual is null; + if (expected.Length != actual.Length) return false; + for (int i = 0; i < expected.Length; i++) + if (!EqualityComparer.Default.Equals(expected[i], actual[i])) return false; + return true; + } + + private static bool EqualColumn(ColumnDefinition expected, ColumnDefinition? actual) + => actual is not null && expected.SchemaId == actual.SchemaId && expected.Name == actual.Name + && expected.Type == actual.Type && expected.Nullable == actual.Nullable + && expected.IsPrimaryKey == actual.IsPrimaryKey && expected.IsIdentity == actual.IsIdentity + && expected.IsRowVersion == actual.IsRowVersion && expected.Collation == actual.Collation + && expected.DefaultSql == actual.DefaultSql && EqualDeclaredType(expected.DeclaredType, actual.DeclaredType); + + private static bool EqualDeclaredType(SqlTypeDescriptor? expected, SqlTypeDescriptor? actual) + { + if (expected is null || actual is null) return expected is null && actual is null; + return expected.Kind == actual.Kind && expected.Length == actual.Length + && expected.Precision == actual.Precision && expected.Scale == actual.Scale + && expected.FractionalSecondsPrecision == actual.FractionalSecondsPrecision; + } + + private static ColumnDefinition CopyColumn(ColumnDefinition column) + => new() + { + SchemaId = column.SchemaId, + Name = column.Name, + Type = column.Type, + DeclaredType = column.DeclaredType is { } type ? new SqlTypeDescriptor + { + Kind = type.Kind, Length = type.Length, Precision = type.Precision, + Scale = type.Scale, FractionalSecondsPrecision = type.FractionalSecondsPrecision, + } : null, + Nullable = column.Nullable, + IsPrimaryKey = column.IsPrimaryKey, + IsIdentity = column.IsIdentity, + IsRowVersion = column.IsRowVersion, + Collation = column.Collation, + DefaultSql = column.DefaultSql, + }; + } +} diff --git a/src/CSharpDB.Daemon/Grpc/CSharpDbRpcService.cs b/src/CSharpDB.Daemon/Grpc/CSharpDbRpcService.cs index 66edccff..3daf49de 100644 --- a/src/CSharpDB.Daemon/Grpc/CSharpDbRpcService.cs +++ b/src/CSharpDB.Daemon/Grpc/CSharpDbRpcService.cs @@ -18,7 +18,7 @@ namespace CSharpDB.Daemon.Grpc; -public sealed class CSharpDbRpcService : CSharpDbRpc.CSharpDbRpcBase +public sealed partial class CSharpDbRpcService : CSharpDbRpc.CSharpDbRpcBase { public override Task ReadDefinitionCatalog(DefinitionCatalogRequest request, ServerCallContext context) => ExecuteAsync(context, @@ -382,9 +382,13 @@ public override Task ExecuteProcedure(ExecutePr => ExecuteAsync(context, ct => client.ExecuteProcedureAsync(request.Name, ReadRequiredObject(request.Args), ct), GrpcModelMapper.ToMessage); public override Task ExecuteSql(SqlRequest request, ServerCallContext context) + => ExecuteSqlResultAsync(request, context, GrpcModelMapper.ToMessage); + + private Task ExecuteSqlResultAsync(SqlRequest request, ServerCallContext context, + Func map) { if (TryCreateStatelessTemporaryTableSqlRejection(request.Sql, out var rejection)) - return Task.FromResult(GrpcModelMapper.ToMessage(rejection)); + return Task.FromResult(map(rejection)); return ExecuteAsync( context, @@ -400,7 +404,7 @@ public override Task ExecuteSql(SqlRequest request, S return result; }, - GrpcModelMapper.ToMessage); + map); } public override Task BeginTransaction(Empty request, ServerCallContext context) diff --git a/src/CSharpDB.Daemon/README.md b/src/CSharpDB.Daemon/README.md index 9996c1b8..ab663013 100644 --- a/src/CSharpDB.Daemon/README.md +++ b/src/CSharpDB.Daemon/README.md @@ -97,6 +97,54 @@ When `Transport = Grpc` connects to a plain `http://` daemon endpoint, default HTTP service URL. HTTPS endpoints and custom gRPC test clients can still use native gRPC. +### Native HTTP/2 gRPC + +The SDK supports `GrpcMode = CSharpDbGrpcMode.Native`, and ADO.NET accepts +`Grpc Mode=Native`. Both require HTTP/2 instead of selecting the default +plain-HTTP gRPC-Web compatibility path. Existing clients remain on `Auto`. + +For a local experiment, configure two loopback listeners in the daemon's +`appsettings.json` using Kestrel's standard endpoint configuration: + +```json +{ + "Kestrel": { + "Endpoints": { + "Compatibility": { + "Url": "http://127.0.0.1:5820", + "Protocols": "Http1" + }, + "NativeGrpc": { + "Url": "http://127.0.0.1:5821", + "Protocols": "Http2" + } + } + } +} +``` + +Merge this section with the existing configuration. REST and gRPC-Web clients +use port `5820`; native gRPC clients use port `5821`. An unencrypted native +gRPC listener must be HTTP/2-only. Remote deployments should use a trusted +HTTPS listener supporting HTTP/2; native mode also works with HTTPS and +retains certificate validation. + +The socket experiment is in +[`tests/CSharpDB.NetworkBenchmarks`](../../tests/CSharpDB.NetworkBenchmarks/README.md). +It uses the actual daemon with dynamically assigned loopback ports and the +same SQL messages and database execution path in both arms. + +The daemon also exposes experimental `CommandSession` protocol version 1 +over native HTTP/2: a capability handshake followed by ordered SQL and +transaction commands on one bidirectional stream. Clients opt in with +`GrpcCallMode = CSharpDbGrpcCallMode.Streaming` or ADO.NET +`Grpc Call Mode=Streaming`, together with explicit native mode. Existing +unary RPCs remain available. Each session permits one command in flight, +binds API-key/routing metadata at opening, and owns its transaction IDs. +When it ends, unfinished transactions receive best-effort rollback with a +five-second deadline. Cleanup failures are logged. No command is replayed +after disconnect. See the [SDK session guide](../CSharpDB.Client/README.md#experimental-persistent-command-sessions). + For remote access to a daemon on another machine, use `Grpc` or `Http` with a normal base address such as: diff --git a/src/CSharpDB.Data/CSharpDbCommand.cs b/src/CSharpDB.Data/CSharpDbCommand.cs index 6630660c..b6789883 100644 --- a/src/CSharpDB.Data/CSharpDbCommand.cs +++ b/src/CSharpDB.Data/CSharpDbCommand.cs @@ -67,6 +67,26 @@ public override async Task ExecuteNonQueryAsync(CancellationToken cancellat public override async Task ExecuteScalarAsync(CancellationToken cancellationToken) { + var connection = (CSharpDbConnection)(DbConnection + ?? throw new InvalidOperationException("Connection is not set.")); + if (connection.GetSession() is RemoteDatabaseSession remote + && !IsTransactionControl(CommandText, out _)) + { + using AdoCommandObservation? observation = connection.StartCommandObservation(CommandText); + try + { + string sql = SqlParameterBinder.Bind(CommandText, _parameters); + return await remote.ExecuteScalarAsync(sql, cancellationToken); + } + catch (Exception ex) + { + observation?.FailBeforeDispatch(ex); + if (ex is CSharpDbException databaseException) + throw new CSharpDbDataException(databaseException); + throw; + } + } + await using var result = await ExecuteQueryAsync(cancellationToken); if (result.IsQuery && result.Schema.Length > 0 && @@ -108,12 +128,7 @@ private async ValueTask ExecuteQueryAsync(CancellationToken cancell var connection = (CSharpDbConnection)(DbConnection ?? throw new InvalidOperationException("Connection is not set.")); var session = connection.GetSession(); - string normalizedCommand = CommandText.Trim().TrimEnd(';').Trim(); - bool isTransactionControl = - normalizedCommand.Equals("START TRANSACTION", StringComparison.OrdinalIgnoreCase) - || normalizedCommand.Equals("BEGIN TRANSACTION", StringComparison.OrdinalIgnoreCase) - || normalizedCommand.Equals("COMMIT", StringComparison.OrdinalIgnoreCase) - || normalizedCommand.Equals("ROLLBACK", StringComparison.OrdinalIgnoreCase); + bool isTransactionControl = IsTransactionControl(CommandText, out string normalizedCommand); using AdoCommandObservation? observation = isTransactionControl ? null : connection.StartCommandObservation(CommandText); @@ -171,6 +186,15 @@ private async ValueTask ExecuteQueryAsync(CancellationToken cancell } } + private static bool IsTransactionControl(string commandText, out string normalizedCommand) + { + normalizedCommand = commandText.Trim().TrimEnd(';').Trim(); + return normalizedCommand.Equals("START TRANSACTION", StringComparison.OrdinalIgnoreCase) + || normalizedCommand.Equals("BEGIN TRANSACTION", StringComparison.OrdinalIgnoreCase) + || normalizedCommand.Equals("COMMIT", StringComparison.OrdinalIgnoreCase) + || normalizedCommand.Equals("ROLLBACK", StringComparison.OrdinalIgnoreCase); + } + public override void Cancel() { /* no-op */ } public override void Prepare() { diff --git a/src/CSharpDB.Data/CSharpDbConnection.cs b/src/CSharpDB.Data/CSharpDbConnection.cs index a8372d72..4331b8a6 100644 --- a/src/CSharpDB.Data/CSharpDbConnection.cs +++ b/src/CSharpDB.Data/CSharpDbConnection.cs @@ -25,6 +25,8 @@ private static readonly ConditionalWeakTable s_sharedPooledOpenPlans = new(); + private static readonly ConditionalWeakTable + s_sharedRemoteOpenPlans = new(); private string _connectionString = ""; private CSharpDbConnectionStringBuilder? _connectionStringBuilder; @@ -144,22 +146,31 @@ public override async Task OpenAsync(CancellationToken cancellationToken) IDisposable? lifecycleBoundary = null; try { - SharedPooledOpenPlan? sharedPooledPlan = GetSharedPooledOpenPlan(); - if (sharedPooledPlan is not null) + if (CanShareRemoteOpenPlan() && + s_sharedRemoteOpenPlans.TryGetValue(_connectionString, out var remotePlan)) { - lifecycleBoundary = EnterDatabaseOpenBoundary( - sharedPooledPlan.Configuration.RuntimeDirectDatabaseOptions); - _session = await OpenPreparedPooledSessionAsync( - sharedPooledPlan, - cancellationToken); + _session = new RemoteDatabaseSession(await CSharpDbClient.RentAsync( + remotePlan.Options, remotePlan.MaxPoolSize, cancellationToken)); } else { - ConnectionOpenPlan plan = GetConnectionOpenPlan(); - TryCacheSharedPooledOpenPlan(plan); - lifecycleBoundary = EnterDatabaseOpenBoundary( - plan.Embedded?.Configuration.RuntimeDirectDatabaseOptions); - _session = await OpenConfiguredSessionAsync(plan, cancellationToken); + SharedPooledOpenPlan? sharedPooledPlan = GetSharedPooledOpenPlan(); + if (sharedPooledPlan is not null) + { + lifecycleBoundary = EnterDatabaseOpenBoundary( + sharedPooledPlan.Configuration.RuntimeDirectDatabaseOptions); + _session = await OpenPreparedPooledSessionAsync( + sharedPooledPlan, + cancellationToken); + } + else + { + ConnectionOpenPlan plan = GetConnectionOpenPlan(); + TryCacheSharedPooledOpenPlan(plan); + lifecycleBoundary = EnterDatabaseOpenBoundary( + plan.Embedded?.Configuration.RuntimeDirectDatabaseOptions); + _session = await OpenConfiguredSessionAsync(plan, cancellationToken); + } } CacheCommandObservabilityOptionsSnapshot( @@ -263,6 +274,11 @@ public static void ClearPool(string connectionString) public static async ValueTask ClearPoolAsync(string connectionString) { var builder = new CSharpDbConnectionStringBuilder(connectionString); + if (!string.IsNullOrWhiteSpace(builder.Endpoint)) + { + await CSharpDbClient.ClearPoolAsync(builder.Endpoint.Trim()); + return; + } if (string.IsNullOrWhiteSpace(builder.DataSource)) return; @@ -298,6 +314,7 @@ public static async ValueTask ClearAllPoolsAsync() { await CSharpDbConnectionPoolRegistry.ClearAllAsync(); await SharedMemoryDatabaseRegistry.ClearAllAsync(); + await CSharpDbClient.ClearAllPoolsAsync(); } public async ValueTask SaveToFileAsync(string filePath, CancellationToken cancellationToken = default) @@ -707,17 +724,29 @@ private async ValueTask OpenRemoteSessionAsync( if (!string.IsNullOrWhiteSpace(builder.LoadFrom)) throw new InvalidOperationException("Load From is only supported for embedded in-memory data sources."); - if (builder.Pooling) - throw new InvalidOperationException("Pooling is not supported for daemon-backed connections."); + if (builder.Pooling && transport != CSharpDbTransport.Grpc) + throw new InvalidOperationException("Remote pooling is currently supported only for the Grpc transport."); string endpoint = builder.Endpoint.Trim(); - ICSharpDbClient client = await OpenClientAsync(new CSharpDbClientOptions + var options = new CSharpDbClientOptions { Transport = transport, Endpoint = endpoint, HttpClient = TransportHttpClient, + GrpcMode = builder.GrpcMode ?? CSharpDbGrpcMode.Auto, + GrpcCallMode = builder.GrpcCallMode ?? CSharpDbGrpcCallMode.Unary, RouteContext = BuildRouteContext(builder), - }, cancellationToken); + }; + + if (builder.Pooling) + { + int maxPoolSize = builder.MaxPoolSize; + ICSharpDbClient rented = await CSharpDbClient.RentAsync(options, maxPoolSize, cancellationToken); + if (CanShareRemoteOpenPlan()) + s_sharedRemoteOpenPlans.GetValue(_connectionString, _ => new SharedRemoteOpenPlan(options, maxPoolSize)); + return new RemoteDatabaseSession(rented); + } + ICSharpDbClient client = await OpenClientAsync(options, cancellationToken); return new RemoteDatabaseSession(client); } @@ -740,6 +769,12 @@ private static PoolKey CreatePoolKey( private CSharpDbConnectionStringBuilder GetConnectionStringBuilder() => _connectionStringBuilder ??= new CSharpDbConnectionStringBuilder(_connectionString); + private bool CanShareRemoteOpenPlan() + => !string.IsNullOrEmpty(_connectionString) && TransportHttpClient is null && + DirectDatabaseOptions is null && HybridDatabaseOptions is null; + + private sealed record SharedRemoteOpenPlan(CSharpDbClientOptions Options, int MaxPoolSize); + private SharedPooledOpenPlan? GetSharedPooledOpenPlan() { if (_sharedPooledOpenPlan is not null) @@ -827,6 +862,12 @@ private ConnectionOpenPlan GetConnectionOpenPlan() CSharpDbConnectionStringBuilder builder = GetConnectionStringBuilder(); string? currentDirectoryKey = GetCurrentDirectoryCacheKey(builder); CSharpDbTransport? configuredTransport = ParseTransportOrNull(builder.Transport); + if (builder.GrpcCallMode == CSharpDbGrpcCallMode.Streaming && + (configuredTransport != CSharpDbTransport.Grpc || builder.GrpcMode != CSharpDbGrpcMode.Native)) + throw new InvalidOperationException("Streaming gRPC requires Transport=Grpc and Grpc Mode=Native."); + if (builder.GrpcMode is { } grpcMode && + grpcMode != CSharpDbGrpcMode.Auto && configuredTransport != CSharpDbTransport.Grpc) + throw new InvalidOperationException("Grpc Mode is only supported for the Grpc transport."); bool hasEmbeddedTuning = CSharpDbEmbeddedConfigurationResolver.HasRequestedTuning( builder, DirectDatabaseOptions, diff --git a/src/CSharpDB.Data/CSharpDbConnectionStringBuilder.cs b/src/CSharpDB.Data/CSharpDbConnectionStringBuilder.cs index 2c9c7214..05e38580 100644 --- a/src/CSharpDB.Data/CSharpDbConnectionStringBuilder.cs +++ b/src/CSharpDB.Data/CSharpDbConnectionStringBuilder.cs @@ -1,5 +1,6 @@ using System.Data.Common; using System.Globalization; +using CSharpDB.Client; namespace CSharpDB.Data; @@ -10,6 +11,8 @@ public sealed class CSharpDbConnectionStringBuilder : DbConnectionStringBuilder private const string LoadFromKey = "Load From"; private const string PoolingKey = "Pooling"; private const string TransportKey = "Transport"; + private const string GrpcModeKey = "Grpc Mode"; + private const string GrpcCallModeKey = "Grpc Call Mode"; private const string MaxPoolSizeKey = "Max Pool Size"; private const string StoragePresetKey = "Storage Preset"; private const string EmbeddedOpenModeKey = "Embedded Open Mode"; @@ -61,6 +64,40 @@ public int MaxPoolSize } } + public CSharpDbGrpcMode? GrpcMode + { + get + { + var mode = GetNullableEnum(GrpcModeKey); + if (mode.HasValue && !Enum.IsDefined(mode.Value)) + throw new FormatException("Grpc Mode must be Auto or Native."); + return mode; + } + set + { + if (value.HasValue && !Enum.IsDefined(value.Value)) + throw new ArgumentOutOfRangeException(nameof(value), "Grpc Mode must be Auto or Native."); + SetNullableEnum(GrpcModeKey, value); + } + } + + public CSharpDbGrpcCallMode? GrpcCallMode + { + get + { + var mode = GetNullableEnum(GrpcCallModeKey); + if (mode.HasValue && !Enum.IsDefined(mode.Value)) + throw new FormatException("Grpc Call Mode must be Unary or Streaming."); + return mode; + } + set + { + if (value.HasValue && !Enum.IsDefined(value.Value)) + throw new ArgumentOutOfRangeException(nameof(value), "Grpc Call Mode must be Unary or Streaming."); + SetNullableEnum(GrpcCallModeKey, value); + } + } + public CSharpDbStoragePreset? StoragePreset { get => GetNullableEnum(StoragePresetKey); diff --git a/src/CSharpDB.Data/CSharpDbTransaction.cs b/src/CSharpDB.Data/CSharpDbTransaction.cs index 1336199e..7e7d9117 100644 --- a/src/CSharpDB.Data/CSharpDbTransaction.cs +++ b/src/CSharpDB.Data/CSharpDbTransaction.cs @@ -61,7 +61,17 @@ protected override void Dispose(bool disposing) { if (disposing && !_completed) { - try { Rollback(); } catch { /* best-effort rollback on dispose */ } + try + { + if (_connection.GetSession() is RemoteDatabaseSession) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + RollbackAsync(timeout.Token).GetAwaiter().GetResult(); + } + else Rollback(); + } + catch { /* best-effort rollback on dispose */ } + finally { _completed = true; _connection.ClearTransaction(); } } base.Dispose(disposing); } @@ -70,7 +80,14 @@ public override async ValueTask DisposeAsync() { if (!_completed) { - try { await RollbackAsync(CancellationToken.None); } catch { } + try + { + using var timeout = _connection.GetSession() is RemoteDatabaseSession + ? new CancellationTokenSource(TimeSpan.FromSeconds(5)) : null; + await RollbackAsync(timeout?.Token ?? CancellationToken.None); + } + catch { } + finally { _completed = true; _connection.ClearTransaction(); } } } } diff --git a/src/CSharpDB.Data/README.md b/src/CSharpDB.Data/README.md index 2028fd98..319de07b 100644 --- a/src/CSharpDB.Data/README.md +++ b/src/CSharpDB.Data/README.md @@ -164,8 +164,77 @@ Connection-string rules: - Embedded/direct mode: use `Data Source=...` - Daemon-backed mode: use `Transport=Grpc;Endpoint=http://...` -- `Data Source`, `Load From`, and connection pooling are embedded-only options +- `Data Source` and `Load From` are embedded-only options +- Remote `Pooling=true` is supported with `Transport=Grpc`; pooling defaults to `false` - `Endpoint` requires an explicit `Transport` +- `Grpc Mode=Native` explicitly requires HTTP/2 with `Transport=Grpc`; + omitted or `Grpc Mode=Auto` preserves the existing compatibility behavior + +For a local HTTP/2-only daemon listener, use: + +```text +Transport=Grpc;Grpc Mode=Native;Endpoint=http://127.0.0.1:5821 +``` + +Configure the listener using the daemon's +[native gRPC guide](../CSharpDB.Daemon/README.md#native-http2-grpc). +This selects the wire protocol. Pooling is a separate option; remote +server-side prepared handles are not implemented. + +### Remote gRPC pooling + +For applications that open and close an ADO.NET connection for each command, +opt into reuse of validated gRPC clients and their channels: + +```text +Transport=Grpc;Endpoint=http://localhost:5000;Pooling=true;Max Pool Size=32 +``` + +The pool lives in `CSharpDB.Client`. ADO.NET rents through +`CSharpDbClient.RentAsync`; direct SDK callers and ADO.NET share retained +connections when endpoint, mode, routing, credentials, HTTP client, and pool +capacity match. See the [SDK pooling API](../CSharpDB.Client/README.md#shared-grpc-pooling). + +Each new physical client performs the existing `GetInfo` check. A warm logical +open rents that client without a validation RPC. `Max Pool Size` (default 32) +limits simultaneous leases per configuration; further opens wait and honor +the `OpenAsync` cancellation token. Pools isolate endpoints, gRPC modes, +routes, credentials, caller-supplied HTTP clients, and pool sizes. + +Closing a logical connection rolls back its unfinished transaction before +returning the client. Failed or canceled RPCs discard the client; commands +are never automatically replayed. Rollback on disposal has a five-second +cleanup deadline. An unacknowledged rollback does not guarantee that the +server released its transaction, and a warm open does not prove the server +is reachable: the next RPC can still fail. + +Families with no active leases expire after two minutes of inactivity, +checked every 30 seconds. `ClearPool`/`ClearPoolAsync` with a remote connection +string retire all configurations for its endpoint; `ClearAllPools` also +clears remote pools. Idle clients are disposed immediately, active leases +are disposed on return, and blocked opens can join a new pool. Caller-owned +`HttpClient` objects remain caller-owned. + +With the default unary call mode, API-key authentication occurs per RPC. +Remote server-side prepared statement handles are not implemented. + +### Experimental persistent command sessions + +```text +Transport=Grpc;Grpc Mode=Native;Grpc Call Mode=Streaming;Endpoint=http://127.0.0.1:5821;Pooling=true;Max Pool Size=32 +``` + +This uses the SDK's experimental persistent native gRPC session for SQL, +transactions, and `GetInfo`, with one command in flight per physical session. +Authentication and protocol negotiation happen when the stream opens. +SDK rentals and ADO.NET connections with matching options share that stream; +unary and streaming call modes use separate pool families. Closing a +connection resets its transactions before reuse. Active cancellation or a +disconnect closes the stream and triggers server transaction cleanup; +commands, including commits, are never replayed automatically. See the +[SDK session guide](../CSharpDB.Client/README.md#experimental-persistent-command-sessions) +for the protocol and failure behavior. Omit `Grpc Call Mode` to retain unary +calls. Explicit `Grpc Mode=Native` is required for streaming. For a daemon host, `Grpc` is the primary transport today: @@ -180,7 +249,7 @@ Transport guidance: - `Direct` with `Data Source=...` is still faster than any network hop when the caller can open the database locally - `Grpc` is the fastest supported network transport in the current CSharpDB stack - `NamedPipes` is not implemented end to end today, so do not target it yet for daemon-backed ADO.NET -- for daemon-backed workloads, reuse open connections instead of reconnecting for each command +- for daemon-backed workloads, reuse open connections or enable gRPC pooling for frequent open/close cycles ## In-Memory Connection Strings diff --git a/src/CSharpDB.Data/RemoteDatabaseSession.cs b/src/CSharpDB.Data/RemoteDatabaseSession.cs index cd32a5bb..ac3798d9 100644 --- a/src/CSharpDB.Data/RemoteDatabaseSession.cs +++ b/src/CSharpDB.Data/RemoteDatabaseSession.cs @@ -1,6 +1,8 @@ using System.Globalization; +using System.Runtime.CompilerServices; using System.Text.Json; using CSharpDB.Client; +using CSharpDB.Client.Internal; using CSharpDB.Client.Models; using CSharpDB.Execution; using CSharpDB.Observability; @@ -23,8 +25,8 @@ namespace CSharpDB.Data; internal sealed class RemoteDatabaseSession : ICSharpDbSession { private ICSharpDbClient? _client; - private readonly Func? _releaseAsync; private string? _transactionId; + private bool _rollbackAttempted; public bool SupportsStructuredExecution => false; public CSharpDbObservabilityOptions? ObservabilityOptionsSnapshot => null; @@ -34,12 +36,9 @@ internal sealed class RemoteDatabaseSession : ICSharpDbSession public ICSharpDbObservabilityClient? RemoteObservabilityClient => Volatile.Read(ref _client) as ICSharpDbObservabilityClient; - internal RemoteDatabaseSession( - ICSharpDbClient client, - Func? releaseAsync = null) + internal RemoteDatabaseSession(ICSharpDbClient client) { _client = client ?? throw new ArgumentNullException(nameof(client)); - _releaseAsync = releaseAsync; } public ValueTask ExecuteAsync(string sql, CancellationToken cancellationToken = default) @@ -97,6 +96,7 @@ public async ValueTask BeginTransactionAsync(CancellationToken cancellationToken TransactionSessionInfo info = await GetClient().BeginTransactionAsync(cancellationToken); _transactionId = info.TransactionId; + _rollbackAttempted = false; } public async ValueTask CommitAsync(CancellationToken cancellationToken = default) @@ -109,6 +109,7 @@ public async ValueTask CommitAsync(CancellationToken cancellationToken = default public async ValueTask RollbackAsync(CancellationToken cancellationToken = default) { string transactionId = _transactionId ?? throw new InvalidOperationException("No transaction is active."); + _rollbackAttempted = true; await GetClient().RollbackTransactionAsync(transactionId, cancellationToken); _transactionId = null; } @@ -141,28 +142,100 @@ public IReadOnlyCollection GetTriggers() public async ValueTask DisposeAsync() { - var client = _client; - _client = null; + var client = Interlocked.Exchange(ref _client, null); + string? transactionId = _transactionId; _transactionId = null; if (client is not null) { - if (_releaseAsync is null) - await client.DisposeAsync(); - else - await _releaseAsync(client); + if (transactionId is not null && !_rollbackAttempted) + { + using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try { await client.RollbackTransactionAsync(transactionId, cleanup.Token); } + catch { /* The SDK rental discards a connection after a failed RPC. */ } + } + await client.DisposeAsync(); } } private async ValueTask ExecuteSqlCoreAsync(string sql, CancellationToken cancellationToken) { - SqlExecutionResult result = _transactionId is null - ? await GetClient().ExecuteSqlAsync(sql, cancellationToken) - : await GetClient().ExecuteInTransactionAsync(_transactionId, sql, cancellationToken); + SqlExecutionResult result = await DispatchSqlAsync(sql, cancellationToken); + ThrowIfFailed(result); + return MaterializeResult(result); + } + /// Each returned ValueTask must be consumed exactly once. + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] + internal async ValueTask ExecuteScalarAsync(string sql, CancellationToken cancellationToken) + { + SqlExecutionResult result; + if (GetClient() is IScalarProjectionClient { SupportsScalarProjection: true } scalarClient) + { + ScalarProjectionResult projected = await scalarClient.ExecuteScalarProjectionAsync( + sql, _transactionId, cancellationToken); + if (projected.HasCompactInt64) + { + DbValue value = DbValue.FromInteger(projected.Int64); + CoreSqlTypeKind? kind = projected.DeclaredType is { } declaredType + ? MapScalarSqlTypeKind(declaredType.Kind, declaredType.Length, declaredType.Precision, + declaredType.Scale, declaredType.FractionalSecondsPrecision) + : null; + cancellationToken.ThrowIfCancellationRequested(); + return kind is CoreSqlTypeKind declaredKind + ? TypeMapper.GetClrValue(value, declaredKind) + : TypeMapper.GetClrValue(value); + } + // The transport already dispatched SQL. Consume its full response + // without issuing another command, including for older/wider results. + result = projected.Fallback + ?? throw new InvalidOperationException("The scalar projection did not return a result."); + } + else + { + result = await DispatchSqlAsync(sql, cancellationToken); + } + ThrowIfFailed(result); + // Preserve conversion/validation of unused cells for wider or older-server results. + // The narrow path uses this response's metadata and never resends the SQL. + if (result.IsQuery && result.ColumnNames is { Length: 1 } && result.Columns is { Length: 1 } + && result.Rows is { Count: 1 } && result.Rows[0] is { Length: 1 } row) + { + DbValue value = ToDbValue(row[0]); + CSharpDB.Client.Models.SqlTypeDescriptor? declaredType = result.Columns[0].DeclaredType; + CoreSqlTypeKind? kind = declaredType is null ? null : MapScalarSqlTypeKind(declaredType); + cancellationToken.ThrowIfCancellationRequested(); + return kind is CoreSqlTypeKind declaredKind + ? TypeMapper.GetClrValue(value, declaredKind) + : TypeMapper.GetClrValue(value); + } + + await using QueryResult materialized = MaterializeResult(result); + if (materialized.IsQuery && materialized.Schema.Length > 0 + && await materialized.MoveNextAsync(cancellationToken)) + { + CoreColumnDefinition column = materialized.Schema[0]; + return column.DeclaredType is null + ? TypeMapper.GetClrValue(materialized.Current[0]) + : TypeMapper.GetClrValue(materialized.Current[0], column.EffectiveType); + } + + return null; + } + + private Task DispatchSqlAsync(string sql, CancellationToken cancellationToken) + => _transactionId is null + ? GetClient().ExecuteSqlAsync(sql, cancellationToken) + : GetClient().ExecuteInTransactionAsync(_transactionId, sql, cancellationToken); + + private static void ThrowIfFailed(SqlExecutionResult result) + { if (!string.IsNullOrWhiteSpace(result.Error)) throw new CSharpDbException(ErrorCode.Unknown, result.Error); + } + private static QueryResult MaterializeResult(SqlExecutionResult result) + { if (!result.IsQuery) return new QueryResult(result.RowsAffected); @@ -237,40 +310,63 @@ private static CoreColumnDefinition MapColumnDefinition(CSharpDB.Client.Models.C private static CSharpDB.Primitives.SqlTypeDescriptor MapSqlTypeDescriptor( CSharpDB.Client.Models.SqlTypeDescriptor type) => CSharpDB.Primitives.SqlTypeDescriptor.Create( - type.Kind switch - { - CSharpDB.Client.Models.SqlTypeKind.Boolean => CSharpDB.Primitives.SqlTypeKind.Boolean, - CSharpDB.Client.Models.SqlTypeKind.TinyInt => CSharpDB.Primitives.SqlTypeKind.TinyInt, - CSharpDB.Client.Models.SqlTypeKind.SmallInt => CSharpDB.Primitives.SqlTypeKind.SmallInt, - CSharpDB.Client.Models.SqlTypeKind.Integer => CSharpDB.Primitives.SqlTypeKind.Integer, - CSharpDB.Client.Models.SqlTypeKind.BigInt => CSharpDB.Primitives.SqlTypeKind.BigInt, - CSharpDB.Client.Models.SqlTypeKind.Real => CSharpDB.Primitives.SqlTypeKind.Real, - CSharpDB.Client.Models.SqlTypeKind.Double => CSharpDB.Primitives.SqlTypeKind.Double, - CSharpDB.Client.Models.SqlTypeKind.Decimal => CSharpDB.Primitives.SqlTypeKind.Decimal, - CSharpDB.Client.Models.SqlTypeKind.Char => CSharpDB.Primitives.SqlTypeKind.Char, - CSharpDB.Client.Models.SqlTypeKind.VarChar => CSharpDB.Primitives.SqlTypeKind.VarChar, - CSharpDB.Client.Models.SqlTypeKind.Text => CSharpDB.Primitives.SqlTypeKind.Text, - CSharpDB.Client.Models.SqlTypeKind.Binary => CSharpDB.Primitives.SqlTypeKind.Binary, - CSharpDB.Client.Models.SqlTypeKind.VarBinary => CSharpDB.Primitives.SqlTypeKind.VarBinary, - CSharpDB.Client.Models.SqlTypeKind.Blob => CSharpDB.Primitives.SqlTypeKind.Blob, - CSharpDB.Client.Models.SqlTypeKind.Uuid => CSharpDB.Primitives.SqlTypeKind.Uuid, - CSharpDB.Client.Models.SqlTypeKind.Date => CSharpDB.Primitives.SqlTypeKind.Date, - CSharpDB.Client.Models.SqlTypeKind.Time => CSharpDB.Primitives.SqlTypeKind.Time, - CSharpDB.Client.Models.SqlTypeKind.Timestamp => CSharpDB.Primitives.SqlTypeKind.Timestamp, - CSharpDB.Client.Models.SqlTypeKind.TimestampWithTimeZone => CSharpDB.Primitives.SqlTypeKind.TimestampWithTimeZone, - CSharpDB.Client.Models.SqlTypeKind.IntervalYearToMonth => CSharpDB.Primitives.SqlTypeKind.IntervalYearToMonth, - CSharpDB.Client.Models.SqlTypeKind.IntervalDayToSecond => CSharpDB.Primitives.SqlTypeKind.IntervalDayToSecond, - CSharpDB.Client.Models.SqlTypeKind.Json => CSharpDB.Primitives.SqlTypeKind.Json, - CSharpDB.Client.Models.SqlTypeKind.Xml => CSharpDB.Primitives.SqlTypeKind.Xml, - CSharpDB.Client.Models.SqlTypeKind.Bit => CSharpDB.Primitives.SqlTypeKind.Bit, - CSharpDB.Client.Models.SqlTypeKind.VarBit => CSharpDB.Primitives.SqlTypeKind.VarBit, - _ => throw new InvalidOperationException($"Unsupported logical SQL type '{type.Kind}'."), - }, + MapSqlTypeKind(type.Kind), type.Length, type.Precision, type.Scale, type.FractionalSecondsPrecision); + private static CoreSqlTypeKind MapScalarSqlTypeKind(CSharpDB.Client.Models.SqlTypeDescriptor type) + => MapScalarSqlTypeKind(type.Kind, type.Length, type.Precision, + type.Scale, type.FractionalSecondsPrecision); + + private static CoreSqlTypeKind MapScalarSqlTypeKind( + CSharpDB.Client.Models.SqlTypeKind declaredKind, int? length, int? precision, + int? scale, int? fractionalSecondsPrecision) + { + CoreSqlTypeKind kind = MapSqlTypeKind(declaredKind); + // Every supported kind accepts absent facets. Keep the existing validator + // for any supplied facet, including zero, and run it before cancellation. + if (length.HasValue || precision.HasValue || scale.HasValue + || fractionalSecondsPrecision.HasValue) + { + _ = CoreSqlTypeDescriptor.Create( + kind, length, precision, scale, fractionalSecondsPrecision); + } + return kind; + } + + private static CoreSqlTypeKind MapSqlTypeKind(CSharpDB.Client.Models.SqlTypeKind kind) + => kind switch + { + CSharpDB.Client.Models.SqlTypeKind.Boolean => CSharpDB.Primitives.SqlTypeKind.Boolean, + CSharpDB.Client.Models.SqlTypeKind.TinyInt => CSharpDB.Primitives.SqlTypeKind.TinyInt, + CSharpDB.Client.Models.SqlTypeKind.SmallInt => CSharpDB.Primitives.SqlTypeKind.SmallInt, + CSharpDB.Client.Models.SqlTypeKind.Integer => CSharpDB.Primitives.SqlTypeKind.Integer, + CSharpDB.Client.Models.SqlTypeKind.BigInt => CSharpDB.Primitives.SqlTypeKind.BigInt, + CSharpDB.Client.Models.SqlTypeKind.Real => CSharpDB.Primitives.SqlTypeKind.Real, + CSharpDB.Client.Models.SqlTypeKind.Double => CSharpDB.Primitives.SqlTypeKind.Double, + CSharpDB.Client.Models.SqlTypeKind.Decimal => CSharpDB.Primitives.SqlTypeKind.Decimal, + CSharpDB.Client.Models.SqlTypeKind.Char => CSharpDB.Primitives.SqlTypeKind.Char, + CSharpDB.Client.Models.SqlTypeKind.VarChar => CSharpDB.Primitives.SqlTypeKind.VarChar, + CSharpDB.Client.Models.SqlTypeKind.Text => CSharpDB.Primitives.SqlTypeKind.Text, + CSharpDB.Client.Models.SqlTypeKind.Binary => CSharpDB.Primitives.SqlTypeKind.Binary, + CSharpDB.Client.Models.SqlTypeKind.VarBinary => CSharpDB.Primitives.SqlTypeKind.VarBinary, + CSharpDB.Client.Models.SqlTypeKind.Blob => CSharpDB.Primitives.SqlTypeKind.Blob, + CSharpDB.Client.Models.SqlTypeKind.Uuid => CSharpDB.Primitives.SqlTypeKind.Uuid, + CSharpDB.Client.Models.SqlTypeKind.Date => CSharpDB.Primitives.SqlTypeKind.Date, + CSharpDB.Client.Models.SqlTypeKind.Time => CSharpDB.Primitives.SqlTypeKind.Time, + CSharpDB.Client.Models.SqlTypeKind.Timestamp => CSharpDB.Primitives.SqlTypeKind.Timestamp, + CSharpDB.Client.Models.SqlTypeKind.TimestampWithTimeZone => CSharpDB.Primitives.SqlTypeKind.TimestampWithTimeZone, + CSharpDB.Client.Models.SqlTypeKind.IntervalYearToMonth => CSharpDB.Primitives.SqlTypeKind.IntervalYearToMonth, + CSharpDB.Client.Models.SqlTypeKind.IntervalDayToSecond => CSharpDB.Primitives.SqlTypeKind.IntervalDayToSecond, + CSharpDB.Client.Models.SqlTypeKind.Json => CSharpDB.Primitives.SqlTypeKind.Json, + CSharpDB.Client.Models.SqlTypeKind.Xml => CSharpDB.Primitives.SqlTypeKind.Xml, + CSharpDB.Client.Models.SqlTypeKind.Bit => CSharpDB.Primitives.SqlTypeKind.Bit, + CSharpDB.Client.Models.SqlTypeKind.VarBit => CSharpDB.Primitives.SqlTypeKind.VarBit, + _ => throw new InvalidOperationException($"Unsupported logical SQL type '{kind}'."), + }; + private static CoreForeignKeyDefinition MapForeignKeyDefinition(CSharpDB.Client.Models.ForeignKeyDefinition foreignKey) => new() { diff --git a/src/CSharpDB.Data/TypeMapper.cs b/src/CSharpDB.Data/TypeMapper.cs index eca58308..a257f2bb 100644 --- a/src/CSharpDB.Data/TypeMapper.cs +++ b/src/CSharpDB.Data/TypeMapper.cs @@ -117,7 +117,15 @@ public static object GetClrValue(DbValue value, SqlTypeDescriptor type) if (value.IsNull) return DBNull.Value; - return type.Kind switch + return GetClrValue(value, type.Kind); + } + + internal static object GetClrValue(DbValue value, SqlTypeKind kind) + { + if (value.IsNull) + return DBNull.Value; + + return kind switch { SqlTypeKind.Boolean => value.AsInteger != 0, SqlTypeKind.TinyInt => checked((byte)value.AsInteger), diff --git a/src/CSharpDB.Engine/Database.cs b/src/CSharpDB.Engine/Database.cs index 322ff10f..7f3fad00 100644 --- a/src/CSharpDB.Engine/Database.cs +++ b/src/CSharpDB.Engine/Database.cs @@ -119,6 +119,7 @@ public ImplicitInsertExecutionMode ImplicitInsertExecutionMode } public int ActiveReaderCount => _pager.ActiveReaderCount; + internal int ActiveQueryReaderCount => _pager.ActiveQueryReaderCount; internal bool IsObservabilityEnabled => _observabilityOptions is not null; @@ -2544,26 +2545,17 @@ private Dictionary CaptureSnapshotRowCounts() internal IDisposable EnterTemporaryTableSessionScope(object sessionKey) => _temporaryTables.EnterSessionScope(sessionKey); + // Preserve hybrid Dispose-trigger persistence at a logical client boundary + // without closing the shared database while other transactions own it. + internal ValueTask PersistClientSessionBoundaryAsync() => + PersistHybridStateAsync(HybridPersistenceTriggers.Dispose, CancellationToken.None); + internal bool HasTemporaryTablesForCurrentSession => _temporaryTables.GetTableNames().Count != 0; internal bool HasTemporaryTableContextForCurrentSession => _temporaryTables.HasCurrentSessionContext; - internal async ValueTask ResetReusableSessionStateAsync() - { - if (_inTransaction || _explicitTransactionFailed) - throw new InvalidOperationException("Cannot reuse a database handle with an active or failed transaction."); - if (ActiveReaderCount != 0) - throw new InvalidOperationException("Cannot reuse a database handle while snapshot readers are active."); - - // A physical close flushes deferred catalog state and destroys every - // temporary-table context. Warm handle handoff must preserve that - // boundary before the handle changes logical owners. - await FlushPendingAdvisoryStatisticsAsync(CancellationToken.None); - await _temporaryTables.ClearAsync(); - } - internal uint GetTableRootPage(string tableName) => _catalog.GetTableRootPage(tableName); /// @@ -2840,8 +2832,10 @@ public async ValueTask> SearchAsync( /// Whether table CHECK expressions can invoke registered host callbacks during an update. public bool HasUpdateHostCallbacks(string tableName) + => HasUpdateHostCallbacks(GetTableSchema(tableName)); + + internal bool HasUpdateHostCallbacks(TableSchema? schema) { - var schema = GetTableSchema(tableName); if (schema is null) return true; foreach (var check in schema.CheckConstraints) { diff --git a/src/CSharpDB.Engine/WriteTransaction.cs b/src/CSharpDB.Engine/WriteTransaction.cs index 2932f57b..50186c04 100644 --- a/src/CSharpDB.Engine/WriteTransaction.cs +++ b/src/CSharpDB.Engine/WriteTransaction.cs @@ -20,6 +20,7 @@ public sealed class WriteTransaction : IAsyncDisposable private readonly LifecycleOperation? _lifecycleOperation; private bool _completed; private bool _writeFailed; + private object? _temporaryTableSessionKey; internal WriteTransaction( Database database, @@ -39,6 +40,89 @@ internal WriteTransaction( _lifecycleOperation = lifecycleOperation; } + // Client sessions share the database owner but have private temporary tables. + // Deferred query iteration must enter this scope along with the pager binding. + internal void SetTemporaryTableSession(object sessionKey) + => _temporaryTableSessionKey = sessionKey; + + private IDisposable BindExecution() + => BindExecution(_storageTransaction.Bind()); + + private IDisposable BindSnapshotExecution() + => BindExecution(_storageTransaction.BindSnapshotRead()); + + private IDisposable BindExecution(IDisposable binding) + { + if (_temporaryTableSessionKey is null) + return binding; + try + { + return new SessionBinding(binding, + _database.EnterTemporaryTableSessionScope(_temporaryTableSessionKey)); + } + catch + { + binding.Dispose(); + throw; + } + } + + private sealed class SessionBinding(IDisposable binding, IDisposable sessionScope) : IDisposable + { + public void Dispose() + { + try { sessionScope.Dispose(); } + finally { binding.Dispose(); } + } + } + + internal TableSchema? GetTableSchema(string tableName) + { + EnsureActive(); + using var scope = BindExecution(); + TableSchema? schema = _catalog.GetTable(tableName); + if (schema is null) + return null; + long nextRowId = schema.NextRowId; + foreach (var hint in _planner.GetCommittedNextRowIdHints()) + if (string.Equals(hint.Key, tableName, StringComparison.OrdinalIgnoreCase)) + nextRowId = Math.Max(nextRowId, hint.Value); + return new TableSchema + { + SchemaId = schema.SchemaId, + TableName = schema.TableName, + Columns = schema.Columns, + ForeignKeys = schema.ForeignKeys, + CheckConstraints = schema.CheckConstraints, + KeyConstraints = schema.KeyConstraints, + QualifiedMappings = schema.QualifiedMappings, + NextRowId = nextRowId, + }; + } + + internal IReadOnlyCollection GetIndexes() => _catalog.GetIndexes(); + internal IReadOnlyCollection GetTriggers() => _catalog.GetTriggers(); + internal bool HasUpdateHostCallbacks(string tableName) + => _database.HasUpdateHostCallbacks(GetTableSchema(tableName)); + + internal async ValueTask ApplyTableSchemaIdentitiesAsync( + string tableName, TableSchema identitySource, CancellationToken ct) + { + EnsureActive(); + EnsureWriteAllowed(); + try + { + using var scope = BindExecution(); + await _storageTransaction.AcquireSchemaWriteLockAsync(ct); + await _catalog.ApplyTableSchemaIdentitiesAsync(tableName, identitySource, ct); + } + catch + { + _writeFailed = true; + throw; + } + } + /// /// Execute SQL within this transaction. /// @@ -64,7 +148,7 @@ private async ValueTask ExecuteSqlCoreAsync(string sql, Cancellatio Statement statement = Parser.TryParseSimpleSelect(sql, out var simpleSelect) ? simpleSelect : Parser.Parse(sql); - return await ExecuteAsyncCore(statement, _storageTransaction.Bind, ct); + return await ExecuteAsyncCore(statement, BindExecution, ct); } /// @@ -91,7 +175,7 @@ private async ValueTask ExecuteStatementRootCoreAsync( ArgumentNullException.ThrowIfNull(statement); EnsureActive(); - return await ExecuteAsyncCore(statement, _storageTransaction.Bind, ct); + return await ExecuteAsyncCore(statement, BindExecution, ct); } internal async ValueTask ExecuteImplicitAutoCommitAsync(Statement statement, CancellationToken ct = default) @@ -138,7 +222,7 @@ private async ValueTask ExecuteSnapshotReadSqlCoreAsync( if (!classification.IsReadOnly) throw new InvalidOperationException("Snapshot-read execution only supports read-only statements."); - return await ExecuteAsyncCore(classification.Statement, _storageTransaction.BindSnapshotRead, ct); + return await ExecuteAsyncCore(classification.Statement, BindSnapshotExecution, ct); } /// @@ -169,7 +253,7 @@ private async ValueTask ExecuteSnapshotReadStatementCoreAsync( if (!SqlStatementClassifier.IsReadOnly(statement)) throw new InvalidOperationException("Snapshot-read execution only supports read-only statements."); - return await ExecuteAsyncCore(statement, _storageTransaction.BindSnapshotRead, ct); + return await ExecuteAsyncCore(statement, BindSnapshotExecution, ct); } /// @@ -315,10 +399,17 @@ private async ValueTask RollbackCoreAsync(CancellationToken ct) public async ValueTask DisposeAsync() { - if (_completed) - return; - - await RollbackAsync(); + try + { + if (!_completed) + await RollbackAsync(); + } + finally + { + // Catalog preparation can fail before commit begins. Always release + // the storage transaction even when the logical transaction ended. + await _storageTransaction.DisposeAsync(); + } } private void EnsureActive() diff --git a/src/CSharpDB.Execution/QueryPlanner.cs b/src/CSharpDB.Execution/QueryPlanner.cs index 6b41dcba..55ac67b3 100644 --- a/src/CSharpDB.Execution/QueryPlanner.cs +++ b/src/CSharpDB.Execution/QueryPlanner.cs @@ -1138,12 +1138,16 @@ private async ValueTask ExecuteCoreAsync(Statement stmt, Cancellati return stmt switch { ConditionalStatement conditional => await ExecuteConditionalAsync(conditional, ct), + CreateTableStatement { IsTemporary: true } createTemporary => + await ExecuteCreateTableAsync(createTemporary, ct), CreateTableStatement create => await ExecuteSchemaMutationAsync( token => ExecuteCreateTableAsync(create, token), ct), CreateExternalTableStatement createExternal => await ExecuteSchemaMutationAsync( token => ExecuteCreateExternalTableAsync(createExternal, token), ct), + DropTableStatement dropTemporary when dropTemporary.IsTemporary || HasTemporaryTable(dropTemporary.TableName) => + await ExecuteDropTableAsync(dropTemporary, ct), DropTableStatement drop => await ExecuteSchemaMutationAsync( token => ExecuteDropTableAsync(drop, token), ct), diff --git a/src/CSharpDB.ImportExport/Serialization/TableArchiveJson.cs b/src/CSharpDB.ImportExport/Serialization/TableArchiveJson.cs index df753f55..8123c515 100644 --- a/src/CSharpDB.ImportExport/Serialization/TableArchiveJson.cs +++ b/src/CSharpDB.ImportExport/Serialization/TableArchiveJson.cs @@ -1,19 +1,23 @@ using System.Text.Json; using System.Text.Json.Serialization; +using CSharpDB.Primitives; namespace CSharpDB.ImportExport.Serialization; internal static class TableArchiveJson { - public static readonly JsonSerializerOptions Options = new() + private static readonly JsonSerializerOptions Options = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true, Converters = { - new JsonStringEnumConverter( - JsonNamingPolicy.CamelCase, - allowIntegerValues: false), + new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false), + new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false), + new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false), + new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false), }, }; + + public static TableArchiveJsonContext Context { get; } = new(Options); } diff --git a/src/CSharpDB.ImportExport/Serialization/TableArchiveJsonContext.cs b/src/CSharpDB.ImportExport/Serialization/TableArchiveJsonContext.cs new file mode 100644 index 00000000..40cf17ee --- /dev/null +++ b/src/CSharpDB.ImportExport/Serialization/TableArchiveJsonContext.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; +using CSharpDB.ImportExport.Models; + +namespace CSharpDB.ImportExport.Serialization; + +[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSerializable(typeof(TableArchiveSchema))] +[JsonSerializable(typeof(TableArchiveManifest))] +[JsonSerializable(typeof(TableArchiveSchemaDocument))] +[JsonSerializable(typeof(TableArchiveManifestDocument))] +internal partial class TableArchiveJsonContext : JsonSerializerContext +{ +} diff --git a/src/CSharpDB.ImportExport/Serialization/TableArchiveReadDocuments.cs b/src/CSharpDB.ImportExport/Serialization/TableArchiveReadDocuments.cs new file mode 100644 index 00000000..b948c746 --- /dev/null +++ b/src/CSharpDB.ImportExport/Serialization/TableArchiveReadDocuments.cs @@ -0,0 +1,129 @@ +using System.Text.Json.Serialization; +using CSharpDB.ImportExport.Models; +using CSharpDB.Primitives; + +namespace CSharpDB.ImportExport.Serialization; + +// Generated constructors for required/init-only models can overwrite property +// initializers when JSON omits additive fields. Mutable read documents preserve +// those legacy defaults, while explicit null still reaches normal validation. +internal sealed class TableArchiveSchemaDocument +{ + public Guid SchemaId { get; set; } + [JsonRequired] public string TableName { get; set; } = null!; + [JsonRequired] public IReadOnlyList Columns { get; set; } = null!; + public IReadOnlyList ForeignKeys { get; set; } = []; + public IReadOnlyList CheckConstraints { get; set; } = []; + public IReadOnlyList KeyConstraints { get; set; } = []; + public IReadOnlyList? SecondaryIndexes { get; set; } + public long NextRowId { get; set; } + + public TableArchiveSchema ToModel() => new() + { + SchemaId = SchemaId, TableName = TableName, Columns = Columns, + ForeignKeys = TableArchiveReadDocuments.Map(ForeignKeys, static item => item.ToModel()), + CheckConstraints = CheckConstraints, KeyConstraints = KeyConstraints, + SecondaryIndexes = TableArchiveReadDocuments.Map(SecondaryIndexes, static item => item.ToModel()), + NextRowId = NextRowId, + }; +} + +internal sealed class TableArchiveForeignKeyDocument +{ + public Guid SchemaId { get; set; } + public IReadOnlyList ColumnSchemaIds { get; set; } = []; + public Guid ReferencedTableSchemaId { get; set; } + public IReadOnlyList ReferencedColumnSchemaIds { get; set; } = []; + public Guid ReferencedKeySchemaId { get; set; } + [JsonRequired] public string ConstraintName { get; set; } = null!; + [JsonRequired] public string ColumnName { get; set; } = null!; + [JsonRequired] public string ReferencedTableName { get; set; } = null!; + [JsonRequired] public string ReferencedColumnName { get; set; } = null!; + public IReadOnlyList ColumnNames { get; set; } = []; + public IReadOnlyList ReferencedColumnNames { get; set; } = []; + public ForeignKeyOnDeleteAction OnDelete { get; set; } + public ForeignKeyOnDeleteAction OnUpdate { get; set; } + [JsonRequired] public string SupportingIndexName { get; set; } = null!; + + public TableArchiveForeignKey ToModel() => new() + { + SchemaId = SchemaId, ColumnSchemaIds = ColumnSchemaIds, + ReferencedTableSchemaId = ReferencedTableSchemaId, ReferencedColumnSchemaIds = ReferencedColumnSchemaIds, + ReferencedKeySchemaId = ReferencedKeySchemaId, ConstraintName = ConstraintName, + ColumnName = ColumnName, ReferencedTableName = ReferencedTableName, + ReferencedColumnName = ReferencedColumnName, ColumnNames = ColumnNames, + ReferencedColumnNames = ReferencedColumnNames, OnDelete = OnDelete, OnUpdate = OnUpdate, + SupportingIndexName = SupportingIndexName, + }; +} + +internal sealed class TableArchiveSecondaryIndexDocument +{ + [JsonRequired] public string Name { get; set; } = null!; + [JsonRequired] public IReadOnlyList Columns { get; set; } = null!; + public IReadOnlyList ColumnCollations { get; set; } = []; + public bool IsUnique { get; set; } + + public TableArchiveSecondaryIndex ToModel() => new() + { + Name = Name, Columns = Columns, ColumnCollations = ColumnCollations, IsUnique = IsUnique, + }; +} + +internal sealed class TableArchiveManifestDocument +{ + public int FormatVersion { get; set; } = TableArchiveManifest.LatestFormatVersion; + [JsonRequired] public string SourceTableName { get; set; } = null!; + public DateTimeOffset CreatedUtc { get; set; } = DateTimeOffset.UtcNow; + public long RowCount { get; set; } + public string SchemaEntry { get; set; } = "native:schema"; + public string RowsEntry { get; set; } = "native:rows"; + public IReadOnlyList Indexes { get; set; } = []; + public TableArchiveDigestsDocument? Digests { get; set; } + + public TableArchiveManifest ToModel() => new() + { + FormatVersion = FormatVersion, SourceTableName = SourceTableName, CreatedUtc = CreatedUtc, + RowCount = RowCount, SchemaEntry = SchemaEntry, RowsEntry = RowsEntry, + Indexes = TableArchiveReadDocuments.Map(Indexes, static item => item.ToModel()), + Digests = Digests?.ToModel(), + }; +} + +internal sealed class TableArchiveIndexDocument +{ + [JsonRequired] public string Name { get; set; } = null!; + [JsonRequired] public string Kind { get; set; } = null!; + [JsonRequired] public string ColumnName { get; set; } = null!; + public int ColumnIndex { get; set; } + public long EntryCount { get; set; } + public string SectionEntry { get; set; } = "native:index:primary-key"; + + public TableArchiveIndexManifest ToModel() => new() + { + Name = Name, Kind = Kind, ColumnName = ColumnName, ColumnIndex = ColumnIndex, + EntryCount = EntryCount, SectionEntry = SectionEntry, + }; +} + +internal sealed class TableArchiveDigestsDocument +{ + public string Algorithm { get; set; } = TableArchiveSectionDigests.Sha256Algorithm; + public string Encoding { get; set; } = TableArchiveSectionDigests.LowercaseHexEncoding; + [JsonRequired] public string Schema { get; set; } = null!; + [JsonRequired] public string Rows { get; set; } = null!; + [JsonRequired] public string PhysicalIndex { get; set; } = null!; + + public TableArchiveSectionDigests ToModel() => new() + { + Algorithm = Algorithm, Encoding = Encoding, Schema = Schema, Rows = Rows, PhysicalIndex = PhysicalIndex, + }; +} + +internal static class TableArchiveReadDocuments +{ + internal static IReadOnlyList Map( + IReadOnlyList? items, Func convert) + where TDocument : class where TModel : class + => items?.Select(item => item is null ? null! : convert(item)).ToArray()!; +} diff --git a/src/CSharpDB.ImportExport/TableArchives/TableArchiveReader.cs b/src/CSharpDB.ImportExport/TableArchives/TableArchiveReader.cs index 5431c191..a1f6e2d7 100644 --- a/src/CSharpDB.ImportExport/TableArchives/TableArchiveReader.cs +++ b/src/CSharpDB.ImportExport/TableArchives/TableArchiveReader.cs @@ -272,7 +272,7 @@ private static async ValueTask ReadNativeSchemaAsync( { byte[] bytes = await ReadSectionAsync(stream, header.SchemaOffset, header.SchemaLength, ct); TableArchiveSchema schema = - JsonSerializer.Deserialize(bytes, TableArchiveJson.Options) + JsonSerializer.Deserialize(bytes, TableArchiveJson.Context.TableArchiveSchemaDocument)?.ToModel() ?? throw new InvalidDataException("The table archive schema is empty."); if (schema.Columns is null) throw new InvalidDataException("The table archive schema columns collection is null."); @@ -319,7 +319,7 @@ private static async ValueTask ReadNativeManifestAsync( CancellationToken ct) { byte[] bytes = await ReadSectionAsync(stream, header.ManifestOffset, header.ManifestLength, ct); - var manifest = JsonSerializer.Deserialize(bytes, TableArchiveJson.Options) + var manifest = JsonSerializer.Deserialize(bytes, TableArchiveJson.Context.TableArchiveManifestDocument)?.ToModel() ?? throw new InvalidDataException("The table archive manifest is empty."); if (manifest.FormatVersion is not ( TableArchiveManifest.CurrentFormatVersion or diff --git a/src/CSharpDB.ImportExport/TableArchives/TableArchiveWriter.cs b/src/CSharpDB.ImportExport/TableArchives/TableArchiveWriter.cs index 2869abc8..d73a8df6 100644 --- a/src/CSharpDB.ImportExport/TableArchives/TableArchiveWriter.cs +++ b/src/CSharpDB.ImportExport/TableArchives/TableArchiveWriter.cs @@ -129,7 +129,7 @@ private static async ValueTask WriteNativeAsync( TableArchiveSchema archiveSchema = TableArchiveSchema.FromTableSchema(schema, secondaryIndexes); byte[] schemaBytes = JsonSerializer.SerializeToUtf8Bytes( archiveSchema, - TableArchiveJson.Options); + TableArchiveJson.Context.TableArchiveSchema); await destination.WriteAsync(schemaBytes, ct); string schemaDigest = EncodeDigest(SHA256.HashData(schemaBytes)); @@ -220,7 +220,7 @@ private static async ValueTask WriteNativeAsync( }, }; long manifestOffset = destination.Position; - byte[] manifestBytes = JsonSerializer.SerializeToUtf8Bytes(manifest, TableArchiveJson.Options); + byte[] manifestBytes = JsonSerializer.SerializeToUtf8Bytes(manifest, TableArchiveJson.Context.TableArchiveManifest); await destination.WriteAsync(manifestBytes, ct); destination.Position = 0; diff --git a/src/CSharpDB.Migration.SqlServer/CSharpDB.Migration.SqlServer.csproj b/src/CSharpDB.Migration.SqlServer/CSharpDB.Migration.SqlServer.csproj index 5987843d..fc28859d 100644 --- a/src/CSharpDB.Migration.SqlServer/CSharpDB.Migration.SqlServer.csproj +++ b/src/CSharpDB.Migration.SqlServer/CSharpDB.Migration.SqlServer.csproj @@ -16,6 +16,8 @@ + + diff --git a/src/CSharpDB.Migration.SqlServer/THIRD-PARTY-NOTICES.md b/src/CSharpDB.Migration.SqlServer/THIRD-PARTY-NOTICES.md index 3d1ddff0..1e863f37 100644 --- a/src/CSharpDB.Migration.SqlServer/THIRD-PARTY-NOTICES.md +++ b/src/CSharpDB.Migration.SqlServer/THIRD-PARTY-NOTICES.md @@ -30,7 +30,7 @@ Microsoft Software License Terms included at | 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.18 | MIT | +| System.Security.Cryptography.Pkcs | 9.0.22 | MIT | | System.Security.Cryptography.ProtectedData | 9.0.18 | MIT | | System.Threading.RateLimiting | 9.0.18 | MIT | diff --git a/src/CSharpDB.Migration/MigrationRejectArtifactPublication.cs b/src/CSharpDB.Migration/MigrationRejectArtifactPublication.cs index 8e43642c..f4dfe06e 100644 --- a/src/CSharpDB.Migration/MigrationRejectArtifactPublication.cs +++ b/src/CSharpDB.Migration/MigrationRejectArtifactPublication.cs @@ -1267,10 +1267,11 @@ private static bool IsAbsentLinuxAclAttribute( return false; } - // ext2/3/4 enforce uid and mode bits. EOPNOTSUPP therefore means the + // ext2/3/4 and tmpfs enforce uid and mode bits. EOPNOTSUPP means the // queried optional NFSv4/richacl family is not implemented, not that // an unverified access policy was accepted. - return unchecked((ulong)(long)status.Type) == LinuxExtFileSystem; + ulong fileSystem = unchecked((ulong)(long)status.Type); + return fileSystem is LinuxExtFileSystem or LinuxTmpFileSystem; } private static void RequireUnixTemporaryIdentity( @@ -1571,6 +1572,7 @@ private sealed record DestinationBinding( private const int UnixAtEmptyPath = 0x1000; private const uint LinuxStatxHardLinkCount = 0x0004; private const ulong LinuxExtFileSystem = 0x0000EF53; + private const ulong LinuxTmpFileSystem = 0x01021994; private const int LinuxNoExtendedAttribute = 61; private const int LinuxOperationNotSupported = 95; private const int DarwinNoEntry = 2; diff --git a/src/CSharpDB.Storage/Paging/Pager.cs b/src/CSharpDB.Storage/Paging/Pager.cs index ed1a0461..0b0c2ab7 100644 --- a/src/CSharpDB.Storage/Paging/Pager.cs +++ b/src/CSharpDB.Storage/Paging/Pager.cs @@ -233,6 +233,10 @@ private set } public int ActiveReaderCount => _checkpoints?.ActiveReaderCount ?? 0; + // Writer snapshots pin WAL history but are not client query readers. + internal int ActiveQueryReaderCount => Math.Max(0, + ActiveReaderCount - (_transactions?.ActiveExplicitTransactionCount ?? 0)); + internal bool TryGetRuntimeDiagnosticsSnapshot(out PagerRuntimeRawSnapshot snapshot) { snapshot = default; diff --git a/src/CSharpDB.Storage/Transactions/PagerWriteTransaction.cs b/src/CSharpDB.Storage/Transactions/PagerWriteTransaction.cs index 431e26d6..e8a2b25c 100644 --- a/src/CSharpDB.Storage/Transactions/PagerWriteTransaction.cs +++ b/src/CSharpDB.Storage/Transactions/PagerWriteTransaction.cs @@ -16,6 +16,9 @@ internal PagerWriteTransaction(Pager pager, PagerTransactionState state) internal IDisposable Bind() => _pager.BindTransaction(_state); + internal ValueTask AcquireSchemaWriteLockAsync(CancellationToken ct) + => _pager.AcquireSchemaWriteLockAsync(ct); + internal IDisposable BindSnapshotRead() => new CombinedBinding(Bind(), _pager.SuppressLogicalReadTracking()); diff --git a/src/CSharpDB.Storage/Transactions/TransactionCoordinator.cs b/src/CSharpDB.Storage/Transactions/TransactionCoordinator.cs index 11aa7b64..e25dfc7e 100644 --- a/src/CSharpDB.Storage/Transactions/TransactionCoordinator.cs +++ b/src/CSharpDB.Storage/Transactions/TransactionCoordinator.cs @@ -41,6 +41,11 @@ internal sealed class TransactionCoordinator : IDisposable internal int ActiveWriterCount => GetRuntimeStateSnapshot().ActiveWriterCount; + internal int ActiveExplicitTransactionCount + { + get { lock (_stateGate) return _activeExplicitTransactions.Count; } + } + internal ( int ActiveWriterCount, bool HasActiveExplicitWriters, diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 3d71cabc..99492199 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -11,7 +11,7 @@ README.md icon.png en - 4.6.6 + 4.7.0 diff --git a/tests/CSharpDB.Daemon.Tests/CSharpDB.Daemon.Tests.csproj b/tests/CSharpDB.Daemon.Tests/CSharpDB.Daemon.Tests.csproj index 7cd88553..01ad6de9 100644 --- a/tests/CSharpDB.Daemon.Tests/CSharpDB.Daemon.Tests.csproj +++ b/tests/CSharpDB.Daemon.Tests/CSharpDB.Daemon.Tests.csproj @@ -27,6 +27,7 @@ + diff --git a/tests/CSharpDB.Daemon.Tests/DirectStreamingDispatchTests.cs b/tests/CSharpDB.Daemon.Tests/DirectStreamingDispatchTests.cs new file mode 100644 index 00000000..084b0b8e --- /dev/null +++ b/tests/CSharpDB.Daemon.Tests/DirectStreamingDispatchTests.cs @@ -0,0 +1,234 @@ +using System.Collections.Concurrent; +using System.Reflection; +using System.Runtime.ExceptionServices; +using CSharpDB.Client; +using CSharpDB.Client.Models; +using CSharpDB.Testing; +using Grpc.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using CoreException = CSharpDB.Primitives.CSharpDbException; +using CoreError = CSharpDB.Primitives.ErrorCode; + +namespace CSharpDB.Daemon.Tests; + +public sealed class DirectStreamingDispatchTests +{ + [Fact(Timeout = 30000)] + public async Task Disposal_DrainsStreamAndUnaryCalls_BeforeRollbackAndReuse() + { + var ct = TestContext.Current.CancellationToken; + var control = new Control { HoldSql = "INSERT INTO dispatch_drain VALUES (1)", HoldMetadata = true }; + await using var host = Host(control); + var options = Options(host); + ICSharpDbClient? first = null; + try + { + first = await CSharpDbClient.RentAsync(options, 1, ct); + Assert.Null((await first.ExecuteSqlAsync("CREATE TABLE dispatch_drain (id INTEGER PRIMARY KEY)", ct)).Error); + string id = (await first.BeginTransactionAsync(ct)).TransactionId; + var direct = first.ExecuteInTransactionAsync(id, control.HoldSql!, ct); + await control.SqlEntered.Task.WaitAsync(TimeSpan.FromSeconds(5), ct); + var unary = first.GetTableNamesAsync(ct); + await control.MetadataEntered.Task.WaitAsync(TimeSpan.FromSeconds(5), ct); + Task disposed = first.DisposeAsync().AsTask(); + Assert.Same(disposed, first.DisposeAsync().AsTask()); + Task waiting = CSharpDbClient.RentAsync(options, 1, ct).AsTask(); + Assert.False(disposed.IsCompleted); + Assert.False(waiting.IsCompleted); + control.SqlRelease.TrySetResult(); + Assert.Null((await direct.WaitAsync(TimeSpan.FromSeconds(5), ct)).Error); + Assert.False(disposed.IsCompleted); + Assert.False(waiting.IsCompleted); + Assert.Equal(0, control.Count(nameof(ICSharpDbClient.RollbackTransactionAsync))); + control.MetadataRelease.TrySetResult(); + Assert.Contains("dispatch_drain", await unary.WaitAsync(TimeSpan.FromSeconds(5), ct)); + await disposed.WaitAsync(TimeSpan.FromSeconds(5), ct); + await using var next = await waiting.WaitAsync(TimeSpan.FromSeconds(5), ct); + Assert.Equal(1, control.Count(nameof(ICSharpDbClient.RollbackTransactionAsync))); + Assert.Equal(0L, (await next.ExecuteSqlAsync("SELECT COUNT(*) FROM dispatch_drain", ct)).Rows![0][0]); + Assert.Equal(1, HttpCount(host, "CommandSession")); + + // Both the direct stream methods and unary admin methods must still + // pass the same disposed-rental guard after its owner is reused. + var before = host.Snapshot().ToDictionary(); + await Assert.ThrowsAsync(() => first.ExecuteSqlAsync("SELECT 42", ct)); + await Assert.ThrowsAsync(() => first.GetInfoAsync(ct)); + await Assert.ThrowsAsync(() => first.CommitTransactionAsync(id, ct)); + await Assert.ThrowsAsync(() => first.GetMaintenanceReportAsync(ct)); + Assert.Equal(before.OrderBy(x => x.Key.Method), host.Snapshot().OrderBy(x => x.Key.Method)); + Assert.Equal(42L, (await next.ExecuteSqlAsync("SELECT 42", ct)).Rows![0][0]); + } + finally + { + control.ReleaseAll(); + if (first is not null) await first.DisposeAsync(); + await CSharpDbClient.ClearPoolAsync(host.NativeEndpoint.AbsoluteUri); + } + } + + [Fact(Timeout = 30000)] + public async Task ForeignTransactionChecks_RemainLocal_WhileUnaryCallIsActive() + { + var ct = TestContext.Current.CancellationToken; + var control = new Control { HoldMetadata = true }; + await using var host = Host(control); + try + { + await using var owner = await CSharpDbClient.RentAsync(Options(host), 2, ct); + await using var other = await CSharpDbClient.RentAsync(Options(host), 2, ct); + string id = (await owner.BeginTransactionAsync(ct)).TransactionId; + var unary = owner.GetTableNamesAsync(ct); + await control.MetadataEntered.Task.WaitAsync(TimeSpan.FromSeconds(5), ct); + try + { + await Assert.ThrowsAsync(() => other.ExecuteInTransactionAsync(id, "SELECT 42", ct)); + await Assert.ThrowsAsync(() => other.CommitTransactionAsync(id, ct)); + await Assert.ThrowsAsync(() => other.RollbackTransactionAsync(id, ct)); + Assert.Equal(0, control.Count(nameof(ICSharpDbClient.ExecuteInTransactionAsync))); + Assert.Equal(0, control.Count(nameof(ICSharpDbClient.CommitTransactionAsync))); + Assert.Equal(0, control.Count(nameof(ICSharpDbClient.RollbackTransactionAsync))); + Assert.False(unary.IsCompleted); + Assert.Equal(42L, (await owner.ExecuteInTransactionAsync(id, "SELECT 42", ct)).Rows![0][0]); + await owner.CommitTransactionAsync(id, ct); + Assert.Equal(1, control.Count(nameof(ICSharpDbClient.ExecuteInTransactionAsync))); + Assert.Equal(1, control.Count(nameof(ICSharpDbClient.CommitTransactionAsync))); + } + finally { control.MetadataRelease.TrySetResult(); } + await unary.WaitAsync(TimeSpan.FromSeconds(5), ct); + Assert.Equal(42L, (await other.ExecuteSqlAsync("SELECT 42", ct)).Rows![0][0]); + } + finally { control.ReleaseAll(); await CSharpDbClient.ClearPoolAsync(host.NativeEndpoint.AbsoluteUri); } + } + + [Theory(Timeout = 30000)] + [InlineData(false)] + [InlineData(true)] + public async Task StreamOrUnaryRpcFailure_UsesSharedPoisonAndRollback_WithoutReplay(bool unaryFailure) + { + var ct = TestContext.Current.CancellationToken; + var control = new Control(); + await using var host = Host(control); + var options = Options(host); + try + { + await using (var first = await CSharpDbClient.RentAsync(options, 1, ct)) + { + Assert.Null((await first.ExecuteSqlAsync("CREATE TABLE dispatch_failure (id INTEGER PRIMARY KEY)", ct)).Error); + string id = (await first.BeginTransactionAsync(ct)).TransactionId; + Assert.Null((await first.ExecuteInTransactionAsync(id, "INSERT INTO dispatch_failure VALUES (1)", ct)).Error); + control.FailMetadata = unaryFailure; + control.FailSql = unaryFailure ? null : "SELECT direct_failure"; + var error = await Assert.ThrowsAsync(() => unaryFailure + ? first.GetTableNamesAsync(ct) : (Task)first.ExecuteSqlAsync("SELECT direct_failure", ct)); + Assert.Equal(CoreError.Busy, error.Code); + var rpc = Assert.IsType(error.InnerException); + Assert.Equal(StatusCode.Aborted, rpc.StatusCode); + Assert.Equal("Busy", rpc.Trailers.GetValue("csharpdb-error-code")); + control.FailMetadata = false; control.FailSql = null; + } + Assert.Equal(1, control.FailureAttempts); + Assert.Equal(1, control.Count(nameof(ICSharpDbClient.RollbackTransactionAsync))); + await using var next = await CSharpDbClient.RentAsync(options, 1, ct); + Assert.Equal(0L, (await next.ExecuteSqlAsync("SELECT COUNT(*) FROM dispatch_failure", ct)).Rows![0][0]); + Assert.Equal(2, HttpCount(host, "CommandSession")); + Assert.Equal(1, control.FailureAttempts); + } + finally { await CSharpDbClient.ClearPoolAsync(host.NativeEndpoint.AbsoluteUri); } + } + + [Fact(Timeout = 30000)] + public async Task SqlErrorResult_IsReturnedOnce_AndDoesNotPoisonTheRental() + { + var ct = TestContext.Current.CancellationToken; + var control = new Control(); + await using var host = Host(control); + var options = Options(host); + try + { + await using (var first = await CSharpDbClient.RentAsync(options, 1, ct)) + { + int before = control.Count(nameof(ICSharpDbClient.ExecuteSqlAsync)); + var result = await first.ExecuteSqlAsync("SELECT sql_result_error", ct); + Assert.Equal("controlled SQL error result", result.Error); + Assert.Equal(before + 1, control.Count(nameof(ICSharpDbClient.ExecuteSqlAsync))); + Assert.NotNull(await first.GetInfoAsync(ct)); + Assert.Empty(await first.GetTableNamesAsync(ct)); + } + await using var next = await CSharpDbClient.RentAsync(options, 1, ct); + Assert.Equal(42L, (await next.ExecuteSqlAsync("SELECT 42", ct)).Rows![0][0]); + Assert.Equal(1, HttpCount(host, "CommandSession")); + } + finally { await CSharpDbClient.ClearPoolAsync(host.NativeEndpoint.AbsoluteUri); } + } + + private static CSharpDbClientOptions Options(GrpcSocketDaemonFixture host) => new() + { + Transport = CSharpDbTransport.Grpc, GrpcMode = CSharpDbGrpcMode.Native, + GrpcCallMode = CSharpDbGrpcCallMode.Streaming, Endpoint = host.NativeEndpoint.AbsoluteUri, + }; + private static long HttpCount(GrpcSocketDaemonFixture host, string method) + => host.Snapshot().Where(x => x.Key.Method.EndsWith("/" + method, StringComparison.Ordinal)).Sum(x => x.Value); + private static GrpcSocketDaemonFixture Host(Control control) => new(configureServices: services => + { + var original = services.Last(x => x.ServiceType == typeof(ICSharpDbClient)); + services.Replace(ServiceDescriptor.Singleton(provider => + { + var real = (ICSharpDbClient)original.ImplementationFactory!(provider); + var proxy = DispatchProxy.Create(); + ((Backend)(object)proxy).Inner = real; + ((Backend)(object)proxy).Control = control; + return proxy; + })); + }); + public sealed class Control + { + public string? HoldSql, FailSql; + public bool HoldMetadata, FailMetadata; + public int FailureAttempts; + public ConcurrentDictionary Calls { get; } = new(); + public TaskCompletionSource SqlEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource SqlRelease { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource MetadataEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource MetadataRelease { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public int Count(string method) => Calls.GetValueOrDefault(method); + public void ReleaseAll() { SqlRelease.TrySetResult(); MetadataRelease.TrySetResult(); } + } + public class Backend : DispatchProxy + { + public ICSharpDbClient Inner = null!; + public Control Control = null!; + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + MethodInfo method = targetMethod!; + Control.Calls.AddOrUpdate(method.Name, 1, static (_, count) => count + 1); + if ((Control.FailMetadata && method.Name == nameof(ICSharpDbClient.GetTableNamesAsync)) + || (Control.FailSql is { } failure && method.Name == nameof(ICSharpDbClient.ExecuteSqlAsync) && Equals(args![0], failure))) + { Interlocked.Increment(ref Control.FailureAttempts); throw new CoreException(CoreError.Busy, "controlled RPC failure"); } + if (method.Name == nameof(ICSharpDbClient.ExecuteSqlAsync) && Equals(args![0], "SELECT sql_result_error")) + return Task.FromResult(new SqlExecutionResult { Error = "controlled SQL error result" }); + if (Control.HoldMetadata && method.Name == nameof(ICSharpDbClient.GetTableNamesAsync)) + return HoldMetadataAsync((CancellationToken)args![0]!); + object? result; + try { result = method.Invoke(Inner, args); } + catch (TargetInvocationException error) when (error.InnerException is not null) + { ExceptionDispatchInfo.Capture(error.InnerException).Throw(); throw; } + if (Control.HoldSql is { } held && method.Name == nameof(ICSharpDbClient.ExecuteInTransactionAsync) && Equals(args![1], held)) + return HoldSqlAsync((Task)result!, (CancellationToken)args[2]!); + return result; + } + private async Task HoldSqlAsync(Task execution, CancellationToken ct) + { + var result = await execution; + Control.SqlEntered.TrySetResult(); + await Control.SqlRelease.Task.WaitAsync(ct); + return result; + } + private async Task> HoldMetadataAsync(CancellationToken ct) + { + Control.MetadataEntered.TrySetResult(); + await Control.MetadataRelease.Task.WaitAsync(ct); + return await Inner.GetTableNamesAsync(ct); + } + } +} diff --git a/tests/CSharpDB.Daemon.Tests/ExchangeTaskLifetimeTests.cs b/tests/CSharpDB.Daemon.Tests/ExchangeTaskLifetimeTests.cs new file mode 100644 index 00000000..45173200 --- /dev/null +++ b/tests/CSharpDB.Daemon.Tests/ExchangeTaskLifetimeTests.cs @@ -0,0 +1,366 @@ +using CSharpDB.Client; +using CSharpDB.Client.Grpc; +using CSharpDB.Client.Internal; +using CSharpDB.Client.Models; +using CSharpDB.Testing; +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; +using Grpc.Core.Interceptors; + +namespace CSharpDB.Daemon.Tests; + +public sealed class ExchangeTaskLifetimeTests +{ + [Fact(Timeout = 30000)] + public async Task PublicSqlInfoAndTransactionTasks_KeepTheirResultsAcrossExchangesAndRentalReturn() + { + var ct = TestContext.Current.CancellationToken; + await using var host = new GrpcSocketDaemonFixture(); + var options = Options(host); + try + { + Task sql; + Task info; + Task transaction; + SqlExecutionResult sqlResult; + DatabaseInfo infoResult; + TransactionSessionInfo transactionResult; + await using (var first = await CSharpDbClient.RentAsync(options, 1, ct)) + { + sql = first.ExecuteSqlAsync("SELECT 111", ct); + sqlResult = await sql; + info = first.GetInfoAsync(ct); + infoResult = await info; + transaction = first.BeginTransactionAsync(ct); + transactionResult = await transaction; + await first.RollbackTransactionAsync(transactionResult.TransactionId, ct); + for (int index = 0; index < 32; index++) + Assert.Equal((long)index, (await first.ExecuteSqlAsync($"SELECT {index}", ct)).Rows![0][0]); + } + await using (var next = await CSharpDbClient.RentAsync(options, 1, ct)) + { + for (int index = 32; index < 64; index++) + Assert.Equal((long)index, (await next.ExecuteSqlAsync($"SELECT {index}", ct)).Rows![0][0]); + } + Assert.Same(sqlResult, await sql); + Assert.Equal(111L, sqlResult.Rows![0][0]); + Assert.Same(infoResult, await info); + Assert.Same(transactionResult, await transaction); + Assert.False(string.IsNullOrEmpty(transactionResult.TransactionId)); + } + finally { await CSharpDbClient.ClearPoolAsync(options.Endpoint!); } + } + + [Theory(Timeout = 30000)] + [InlineData(false)] + [InlineData(true)] + public async Task PublicSqlFailureTask_KeepsItsErrorOrCancellationAfterLaterRental(bool cancel) + { + var ct = TestContext.Current.CancellationToken; + await using var host = new GrpcSocketDaemonFixture(); + var options = Options(host); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(ct); + try + { + Task retained; + SqlExecutionResult? failedResult = null; + string? sqlError = null; + await using (var first = await CSharpDbClient.RentAsync(options, 1, ct)) + { + if (cancel) + { + cancellation.Cancel(); + retained = first.ExecuteSqlAsync("SELECT 111", cancellation.Token); + var error = await Assert.ThrowsAnyAsync(() => retained); + Assert.Equal(cancellation.Token, error.CancellationToken); + } + else + { + retained = first.ExecuteSqlAsync("SELECT * FROM exchange_lifetime_missing_table", ct); + failedResult = await retained; + sqlError = failedResult.Error; + Assert.False(string.IsNullOrEmpty(sqlError)); + Assert.Equal(17L, (await first.ExecuteSqlAsync("SELECT 17", ct)).Rows![0][0]); + } + } + await using (var next = await CSharpDbClient.RentAsync(options, 1, ct)) + { + for (int index = 0; index < 32; index++) + Assert.Equal((long)index, (await next.ExecuteSqlAsync($"SELECT {index}", ct)).Rows![0][0]); + } + if (cancel) + { + var error = await Assert.ThrowsAnyAsync(() => retained); + Assert.Equal(cancellation.Token, error.CancellationToken); + Assert.True(retained.IsCanceled); + } + else + { + Assert.Same(failedResult, await retained); + Assert.Equal(sqlError, (await retained).Error); + } + } + finally { await CSharpDbClient.ClearPoolAsync(options.Endpoint!); } + } + + [Theory(Timeout = 30000)] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public async Task TaskConsumers_KeepMixedCompletionsAcrossSequentialSessionReuse(int consumer) + { + var retained = new List(); + for (int index = 0; index < 48; index++) + { + TestContext.Current.CancellationToken.ThrowIfCancellationRequested(); + bool hold = index / 4 % 2 == 1; + await using var work = new Work(consumer, index, (Outcome)(index % 4), hold); + Assert.Equal(!hold, work.Operation.IsCompleted); + if (hold) work.Peer.Complete(); + retained.Add(await ObserveAsync(work)); + if (index % 8 == 7) await CheckRetainedAsync(retained); + } + await CheckRetainedAsync(retained); + } + + [Theory(Timeout = 30000)] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public async Task TaskConsumers_KeepIndependentMixedCompletionsAcrossConcurrentSessions(int consumer) + { + var retained = new List(); + for (int batch = 0; batch < 6; batch++) + { + Work[] work = Enumerable.Range(0, 12) + .Select(lane => new Work(consumer, 1000 + batch * 12 + lane, (Outcome)(lane % 4), hold: true)).ToArray(); + try + { + Assert.All(work, item => Assert.False(item.Operation.IsCompleted)); + Task[] observations = work.Select(ObserveAsync).ToArray(); + foreach (int lane in batch % 2 == 0 ? Enumerable.Range(0, 12).Reverse() : Enumerable.Range(0, 12)) + work[lane].Peer.Complete(); + retained.AddRange(await Task.WhenAll(observations) + .WaitAsync(TimeSpan.FromSeconds(15), TestContext.Current.CancellationToken)); + } + finally + { + foreach (Work item in work) await item.DisposeAsync(); + } + await CheckRetainedAsync(retained); + } + } + + private static CSharpDbClientOptions Options(GrpcSocketDaemonFixture host) => new() + { + Transport = CSharpDbTransport.Grpc, GrpcMode = CSharpDbGrpcMode.Native, + GrpcCallMode = CSharpDbGrpcCallMode.Streaming, Endpoint = host.NativeEndpoint.AbsoluteUri, + }; + + private static async Task ObserveAsync(Work work) + { + object? result = null; + RpcException? failure = null; + CancellationToken? canceledToken = null; + switch (work.Outcome) + { + case Outcome.Success: + result = await ReadResultAsync(work); + if (result is SqlExecutionResultMessage sql) Assert.Equal(work.Index, sql.RowsAffected); + else Assert.Equal(work.Peer.Owner, Assert.IsType(result).DataSource); + break; + case Outcome.WireError: + case Outcome.TransportError: + failure = await Assert.ThrowsAsync(() => work.Operation); + Assert.Equal(work.Outcome == Outcome.WireError ? StatusCode.InvalidArgument : StatusCode.Internal, failure.StatusCode); + Assert.Equal(work.Peer.Owner, failure.Status.Detail); + Assert.Equal(work.Peer.Owner, failure.Trailers.GetValue("x-owner")); + break; + case Outcome.Canceled: + var canceled = await Assert.ThrowsAnyAsync(() => work.Operation); + canceledToken = canceled.CancellationToken; + Assert.True(canceledToken.Value.IsCancellationRequested); + if (work.Consumer != 2) Assert.Equal(work.Token, canceledToken.Value); + break; + } + Assert.Equal(1, work.Peer.CommandWrites); + Metadata? headers = work.Headers is null ? null : await work.Headers; + if (headers is not null) Assert.Equal(work.Peer.Owner, headers.GetValue("x-owner")); + var observed = new Observation(work, result, failure, canceledToken, headers); + CheckUnaryCompletion(observed); + return observed; + } + + private static async Task CheckRetainedAsync(IEnumerable retained) + { + foreach (Observation item in retained) + { + if (item.Failure is not null) + Assert.Same(item.Failure, await Assert.ThrowsAsync(() => item.Work.Operation)); + else if (item.CanceledToken is not null) + { + var error = await Assert.ThrowsAnyAsync(() => item.Work.Operation); + Assert.Equal(item.CanceledToken.Value, error.CancellationToken); + Assert.True(item.Work.Operation.IsCanceled); + } + else Assert.Same(item.Result, await ReadResultAsync(item.Work)); + if (item.Work.Headers is not null) Assert.Same(item.Headers, await item.Work.Headers); + CheckUnaryCompletion(item); + } + } + + private static void CheckUnaryCompletion(Observation item) + { + if (item.Work.Unary is not { } call) return; + Assert.Same(item.Work.Operation, call.ResponseAsync); + StatusCode expected = item.Work.Outcome switch + { + Outcome.Success => StatusCode.OK, + Outcome.WireError => StatusCode.InvalidArgument, + Outcome.TransportError => StatusCode.Internal, + _ => StatusCode.Cancelled, + }; + Assert.Equal(expected, call.GetStatus().StatusCode); + if (item.Failure is not null) Assert.Equal(item.Work.Peer.Owner, call.GetTrailers().GetValue("x-owner")); + else Assert.Empty(call.GetTrailers()); + } + + private static async Task ReadResultAsync(Work work) + => work.Consumer == 0 + ? (await (Task)work.Operation).Response + : await (Task)work.Operation; + + private enum Outcome { Success, WireError, TransportError, Canceled } + private sealed record Observation(Work Work, object? Result, RpcException? Failure, + CancellationToken? CanceledToken, Metadata? Headers); + + private sealed class Work : IAsyncDisposable + { + private readonly CancellationTokenSource _cancellation = + CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + private readonly GrpcCommandSession _session; + public int Consumer { get; } + public int Index { get; } + public Outcome Outcome { get; } + public CancellationToken Token { get; } + public Peer Peer { get; } + public Task Operation { get; } + public AsyncUnaryCall? Unary { get; } + public Task? Headers { get; } + public Work(int consumer, int index, Outcome outcome, bool hold) + { + Consumer = consumer; Index = index; Outcome = outcome; Token = _cancellation.Token; + Peer = new Peer(index, outcome, hold, _cancellation); + var invoker = new ScriptedInvoker(Peer); + _session = new GrpcCommandSession(invoker); + if (consumer == 0) + Operation = _session.ExecuteSqlAsync(new() { ExecuteSql = new() { Sql = "SELECT ignored" } }, Token); + else if (consumer == 1) + Operation = _session.ExecuteAsync(new() { GetInfo = new Empty() }, Token); + else + { + var client = new CSharpDbRpc.CSharpDbRpcClient(invoker.Intercept(_session)); + Unary = client.GetInfoAsync(new Empty(), cancellationToken: Token); + Operation = Unary.ResponseAsync; + Headers = Unary.ResponseHeadersAsync; + } + } + public async ValueTask DisposeAsync() + { + _cancellation.Cancel(); + Peer.Abort(); + await _session.DisposeAsync(); + Unary?.Dispose(); + _cancellation.Dispose(); + } + } + + private sealed class Peer(int index, Outcome outcome, bool hold, CancellationTokenSource cancellation) + { + private readonly TaskCompletionSource _reply = new(TaskCreationOptions.RunContinuationsAsynchronously); + private CommandSessionRequest? _command; + private bool _helloPending; + public string Owner { get; } = "exchange-owner-" + index; + public int CommandWrites { get; private set; } + public bool Completed { get; private set; } + public void Write(CommandSessionRequest request) + { + if (request.Hello is not null) { _helloPending = true; return; } + Assert.Null(_command); + _command = request.Clone(); CommandWrites++; + if (!hold) Complete(); + } + public void Complete() + { + Assert.NotNull(_command); + if (outcome == Outcome.Canceled) + { + cancellation.Cancel(); + _reply.TrySetCanceled(cancellation.Token); + } + else if (outcome == Outcome.TransportError) + _reply.TrySetException(new RpcException(new Status(StatusCode.Internal, Owner), new Metadata { { "x-owner", Owner } })); + else + { + var response = new CommandSessionResponse { RequestId = _command.RequestId }; + if (outcome == Outcome.WireError) + { + response.Error = new() { StatusCode = (int)StatusCode.InvalidArgument, Detail = Owner }; + response.Error.Trailers.Add(new CommandSessionMetadata { Key = "x-owner", Text = Owner }); + } + else if (_command.ExecuteSql is not null) + response.Result = new() { IsQuery = false, RowsAffected = index, Elapsed = new Duration() }; + else response.Info = new() { DataSource = Owner, TableCount = index }; + _reply.TrySetResult(response); + } + } + public Task ReadAsync(CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (_helloPending) + { + _helloPending = false; + return Task.FromResult(new() { Ready = new() + { ProtocolVersion = 1, MaxInFlight = 1, Capabilities = { "sql", "transactions" } } }); + } + if (Completed) return Task.FromResult(null); + return WaitReplyAsync(ct); + } + private async Task WaitReplyAsync(CancellationToken ct) + => await _reply.Task.WaitAsync(ct).ConfigureAwait(false); + public void Finish() => Completed = true; + public void Abort() => _reply.TrySetCanceled(); + } + + private sealed class ScriptedInvoker(Peer peer) : CallInvoker + { + public override AsyncDuplexStreamingCall AsyncDuplexStreamingCall( + Method method, string? host, CallOptions options) + => new((IClientStreamWriter)(object)new Writer(peer), + (IAsyncStreamReader)(object)new Reader(peer), + Task.FromResult(new Metadata { { "x-owner", peer.Owner } }), + () => Status.DefaultSuccess, () => new Metadata(), () => { }); + public override TResponse BlockingUnaryCall(Method method, string? host, CallOptions options, TRequest request) => throw new NotSupportedException(); + public override AsyncUnaryCall AsyncUnaryCall(Method method, string? host, CallOptions options, TRequest request) => throw new NotSupportedException(); + public override AsyncServerStreamingCall AsyncServerStreamingCall(Method method, string? host, CallOptions options, TRequest request) => throw new NotSupportedException(); + public override AsyncClientStreamingCall AsyncClientStreamingCall(Method method, string? host, CallOptions options) => throw new NotSupportedException(); + private sealed class Writer(Peer state) : IClientStreamWriter + { + public WriteOptions? WriteOptions { get; set; } + public Task WriteAsync(CommandSessionRequest message) { state.Write(message); return Task.CompletedTask; } + public Task WriteAsync(CommandSessionRequest message, CancellationToken ct) { ct.ThrowIfCancellationRequested(); return WriteAsync(message); } + public Task CompleteAsync() { state.Finish(); return Task.CompletedTask; } + } + private sealed class Reader(Peer state) : IAsyncStreamReader + { + public CommandSessionResponse Current { get; private set; } = null!; + public async Task MoveNext(CancellationToken ct) + { + var response = await state.ReadAsync(ct).ConfigureAwait(false); + if (response is null) return false; + Current = response; + return true; + } + } + } +} diff --git a/tests/CSharpDB.Daemon.Tests/GrpcCompactScalarTests.cs b/tests/CSharpDB.Daemon.Tests/GrpcCompactScalarTests.cs new file mode 100644 index 00000000..a737fc94 --- /dev/null +++ b/tests/CSharpDB.Daemon.Tests/GrpcCompactScalarTests.cs @@ -0,0 +1,392 @@ +using System.Collections.Concurrent; +using System.Reflection; +using System.Runtime.ExceptionServices; +using CSharpDB.Client; +using CSharpDB.Client.Grpc; +using CSharpDB.Client.Models; +using CSharpDB.Testing; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; +using Grpc.Core.Interceptors; +using Grpc.Net.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using ModelColumn = CSharpDB.Client.Models.ColumnDefinition; +using ModelType = CSharpDB.Client.Models.DbType; +using ModelSqlKind = CSharpDB.Client.Models.SqlTypeKind; +using ModelSqlType = CSharpDB.Client.Models.SqlTypeDescriptor; + +namespace CSharpDB.Daemon.Tests; + +public sealed class GrpcCompactScalarTests +{ + private const string ScalarCapability = "single-int64-result-v1"; + private const string SchemaCapability = "result-schema-ref-v1"; + + [Theory(Timeout = 30000)] + [InlineData(0L)] + [InlineData(1L)] + [InlineData(-1L)] + [InlineData(42L)] + [InlineData(long.MinValue)] + [InlineData(long.MaxValue)] + public async Task SignedLongs_PreservePresenceAndValue_AcrossActualWireAndProtobufRoundtrip(long value) + { + var ct = TestContext.Current.CancellationToken; + var control = new Control { Result = One(value) }; + await using var host = Host(control); + await using var raw = await RawSession.OpenAsync(host, schema: false, scalar: true, ct); + var response = await raw.QueryAsync(ct); + Assert.True(response.Result.HasScalarInt64); + Assert.Equal(value, response.Result.ScalarInt64); + Assert.Null(response.Result.Rows); + var roundtrip = CommandSessionResponse.Parser.ParseFrom(response.ToByteArray()); + Assert.True(roundtrip.Result.HasScalarInt64); + Assert.Equal(value, roundtrip.Result.ScalarInt64); + Assert.Null(roundtrip.Result.Rows); + await using var sdk = CSharpDbClient.Create(Options(host)); + var model = await sdk.ExecuteSqlAsync("SELECT controlled_scalar", ct); + Assert.Equal(value, Assert.IsType(Assert.Single(Assert.Single(model.Rows!)))); + Assert.Equal(2, control.SqlCalls); + } + + [Theory(Timeout = 30000)] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task ScalarAndSchemaCapabilities_NegotiateIndependently(bool schema, bool scalar) + { + var ct = TestContext.Current.CancellationToken; + var control = new Control { Result = One(42L) }; + await using var host = Host(control); + await using var raw = await RawSession.OpenAsync(host, schema, scalar, ct); + Assert.Equal(schema, raw.Ready.Capabilities.Contains(SchemaCapability)); + Assert.Equal(scalar, raw.Ready.Capabilities.Contains(ScalarCapability)); + var first = await raw.QueryAsync(ct); + var repeat = await raw.QueryAsync(ct); + foreach (var response in new[] { first, repeat }) + { + Assert.Equal(scalar, response.Result.HasScalarInt64); + if (scalar) { Assert.Null(response.Result.Rows); Assert.Equal(42L, response.Result.ScalarInt64); } + else Assert.NotNull(response.Result.Rows); + } + Assert.False(first.ResultSchemaReference); + Assert.Equal(schema ? 1UL : 0UL, first.ResultSchemaId); + Assert.Equal(schema ? 1UL : 0UL, repeat.ResultSchemaId); + Assert.Equal(schema, repeat.ResultSchemaReference); + Assert.Equal(schema, repeat.Result.ColumnNames is null); + } + + [Theory(Timeout = 30000)] + [InlineData("bool")] + [InlineData("null")] + [InlineData("decimal")] + [InlineData("int")] + [InlineData("double")] + [InlineData("empty")] + [InlineData("multirow")] + [InlineData("multicol")] + [InlineData("missing-authority")] + [InlineData("bad-names-width")] + [InlineData("bad-types-width")] + [InlineData("bad-nullability-width")] + [InlineData("bad-columns-width")] + [InlineData("nonquery")] + [InlineData("error")] + public async Task IneligibleResults_KeepTheCompleteGenericRowRepresentation(string shape) + { + var ct = TestContext.Current.CancellationToken; + var result = Fallback(shape); + var control = new Control { Result = result }; + await using var host = Host(control); + await using var raw = await RawSession.OpenAsync(host, schema: false, scalar: true, ct); + var response = await raw.QueryAsync(ct); + Assert.False(response.Result.HasScalarInt64); + var expected = GrpcModelMapper.ToMessage(result); + Assert.Equal(expected, response.Result); + Assert.Equal(1, control.SqlCalls); + } + + [Fact(Timeout = 30000)] + public async Task CompactResults_UseFreshPublicRowsAndCurrentMetadata_AcrossPoolReuse() + { + var ct = TestContext.Current.CancellationToken; + var control = new Control { Result = One(42L, ModelSqlKind.Integer) }; + await using var host = Host(control); + var options = Options(host); + try + { + SqlExecutionResult first; + await using (var rental = await CSharpDbClient.RentAsync(options, 1, ct)) + { + first = await rental.ExecuteSqlAsync("SELECT controlled_scalar", ct); + first.Rows![0][0] = "poison"; + first.Rows!.Add(new object?[] { "extra row" }); + first.ColumnNames![0] = "poison"; + first.Columns![0] = new() { Name = "poison", Type = ModelType.Blob }; + } + await using var next = await CSharpDbClient.RentAsync(options, 1, ct); + var repeat = await next.ExecuteSqlAsync("SELECT controlled_scalar", ct); + Assert.Equal(42L, Assert.IsType(Assert.Single(Assert.Single(repeat.Rows!)))); + Assert.Equal("value", Assert.Single(repeat.ColumnNames!)); + Assert.Equal(ModelSqlKind.Integer, Assert.Single(repeat.Columns!).DeclaredType!.Kind); + Assert.NotSame(first.Rows, repeat.Rows); + Assert.NotSame(first.Rows![0], repeat.Rows![0]); + Assert.NotSame(first.ColumnNames, repeat.ColumnNames); + Assert.NotSame(first.Columns, repeat.Columns); + Volatile.Write(ref control.Result, One(long.MaxValue, ModelSqlKind.BigInt)); + var changed = await next.ExecuteSqlAsync("SELECT controlled_scalar", ct); + Assert.Equal(long.MaxValue, Assert.IsType(Assert.Single(Assert.Single(changed.Rows!)))); + Assert.Equal(ModelSqlKind.BigInt, Assert.Single(changed.Columns!).DeclaredType!.Kind); + var frames = control.Responses.Where(r => r.Result is not null).ToArray(); + Assert.Equal(3, frames.Length); + Assert.All(frames, frame => Assert.True(frame.Result.HasScalarInt64)); + Assert.Equal(1UL, frames[0].ResultSchemaId); + Assert.True(frames[1].ResultSchemaReference); + Assert.Equal(2UL, frames[2].ResultSchemaId); + Assert.False(frames[2].ResultSchemaReference); + Assert.Equal(1, HttpCount(host, "CommandSession")); + } + finally { await CSharpDbClient.ClearPoolAsync(host.NativeEndpoint.AbsoluteUri); } + } + + [Fact(Timeout = 30000)] + public async Task NonNegotiatingPeer_LeavesNewClientOnLegacyRows_AndUnaryStaysLegacy() + { + var ct = TestContext.Current.CancellationToken; + var control = new Control { Result = One(42L), Mode = "no-echo" }; + await using var host = Host(control); + await using var stream = CSharpDbClient.Create(Options(host)); + for (int n = 0; n < 2; n++) + Assert.Equal(42L, (await stream.ExecuteSqlAsync("SELECT controlled_scalar", ct)).Rows![0][0]); + Assert.DoesNotContain(ScalarCapability, control.Responses.Single(r => r.Ready is not null).Ready.Capabilities); + Assert.All(control.Responses.Where(r => r.Result is not null), r => + { Assert.False(r.Result.HasScalarInt64); Assert.NotNull(r.Result.Rows); }); + await using var unary = host.CreateClient(CSharpDbGrpcMode.Native); + Assert.Equal(42L, (await unary.ExecuteSqlAsync("SELECT controlled_scalar", ct)).Rows![0][0]); + Assert.Equal(1, HttpCount(host, "ExecuteSql")); + } + + [Fact(Timeout = 30000)] + public async Task Compact42_RemovesSixProtobufBytes_WithoutChangingThePublicRows() + { + var ct = TestContext.Current.CancellationToken; + var control = new Control { Result = One(42L) }; + await using var host = Host(control); + await using var ordinary = await RawSession.OpenAsync(host, schema: true, scalar: false, ct); + await using var compact = await RawSession.OpenAsync(host, schema: true, scalar: true, ct); + var legacy = await ordinary.QueryAsync(ct); + var encoded = await compact.QueryAsync(ct); + Assert.NotNull(legacy.Result.Rows); Assert.False(legacy.Result.HasScalarInt64); + Assert.Null(encoded.Result.Rows); Assert.True(encoded.Result.HasScalarInt64); + int legacyBytes = legacy.CalculateSize(), compactBytes = encoded.CalculateSize(); + Assert.Equal(6, legacyBytes - compactBytes); + TestContext.Current.TestOutputHelper?.WriteLine($"Int64=42 normalized protobuf bytes: ordinary={legacyBytes}, compact={compactBytes}, saved={legacyBytes - compactBytes}; excludes gRPC/HTTP2/TCP framing."); + } + + [Theory(Timeout = 30000)] + [InlineData("mixed-empty")] + [InlineData("mixed-filled")] + [InlineData("unnegotiated")] + [InlineData("bad-width")] + [InlineData("reference-width")] + [InlineData("nonquery")] + [InlineData("error")] + [InlineData("missing-metadata")] + public async Task MalformedCompactFrame_ClosesSession_WithoutReplayingTheCommand(string mode) + { + var ct = TestContext.Current.CancellationToken; + var control = new Control { Result = mode == "reference-width" ? Fallback("multicol") : One(42L), Mode = mode }; + await using var host = Host(control); + await using var client = CSharpDbClient.Create(Options(host)); + bool warm = mode == "reference-width"; + if (warm) Assert.Equal(2, (await client.ExecuteSqlAsync("SELECT controlled_scalar", ct)).ColumnNames!.Length); + var error = await Assert.ThrowsAsync(() => client.ExecuteSqlAsync("SELECT controlled_scalar", ct)); + Assert.Equal(StatusCode.DataLoss, Assert.IsType(error.InnerException).StatusCode); + await Assert.ThrowsAsync(() => client.ExecuteSqlAsync("SELECT no_replay", ct)); + Assert.Equal(warm ? 2 : 1, control.SqlCalls); + Assert.Equal(1, HttpCount(host, "CommandSession")); + } + + [Fact] + public void PublicUnaryMapper_RejectsCompactPayloadInsteadOfSilentlyDroppingIt() + { + var message = new SqlExecutionResultMessage + { + IsQuery = true, ScalarInt64 = 0, Elapsed = new Duration(), + ColumnNames = new StringList { Values = { "value" } }, + }; + Assert.True(message.HasScalarInt64); + Assert.Throws(() => GrpcModelMapper.ToModel(message)); + } + + private static SqlExecutionResult One(object? value, ModelSqlKind kind = ModelSqlKind.BigInt) + => new() + { + IsQuery = true, ColumnNames = ["value"], ColumnTypes = [kind.ToString().ToUpperInvariant()], + ColumnNullability = [value is null], Columns = [Column("value", kind, value is null)], + Rows = [new object?[] { value }], RowsAffected = 1, + }; + private static ModelColumn Column(string name, ModelSqlKind kind, bool nullable = false) => new() + { + Name = name, Type = kind == ModelSqlKind.Decimal ? ModelType.Decimal : kind == ModelSqlKind.Double ? ModelType.Real : ModelType.Integer, + Nullable = nullable, DeclaredType = new ModelSqlType { Kind = kind }, + }; + private static SqlExecutionResult Fallback(string shape) + { + object? value = shape switch { "bool" => true, "null" => null, "decimal" => 12.34m, "int" => 42, "double" => 42d, _ => 42L }; + ModelSqlKind kind = shape switch { "bool" => ModelSqlKind.Boolean, "decimal" => ModelSqlKind.Decimal, "double" => ModelSqlKind.Double, _ => ModelSqlKind.BigInt }; + string[]? names = ["value"], types = [kind.ToString().ToUpperInvariant()]; + bool[]? nullability = [value is null]; ModelColumn[]? columns = [Column("value", kind, value is null)]; + List? rows = [new object?[] { value }]; + bool query = true; string? error = null; + switch (shape) + { + case "empty": rows = []; break; + case "multirow": rows!.Add(new object?[] { 43L }); break; + case "multicol": + names = ["value", "other"]; types = ["BIGINT", "BIGINT"]; nullability = [false, false]; + columns = [Column("value", kind), Column("other", kind)]; rows = [new object?[] { 42L, 43L }]; break; + case "missing-authority": names = null; columns = null; break; + case "bad-names-width": names = []; break; + case "bad-types-width": types = []; break; + case "bad-nullability-width": nullability = []; break; + case "bad-columns-width": columns = []; break; + case "nonquery": query = false; names = null; types = null; nullability = null; columns = null; rows = null; break; + case "error": error = "controlled SQL error"; break; + } + return new() { IsQuery = query, Error = error, ColumnNames = names, ColumnTypes = types, ColumnNullability = nullability, + Columns = columns, Rows = rows, RowsAffected = rows?.Count ?? 1 }; + } + + private static CSharpDbClientOptions Options(GrpcSocketDaemonFixture host) => new() + { Transport = CSharpDbTransport.Grpc, GrpcMode = CSharpDbGrpcMode.Native, GrpcCallMode = CSharpDbGrpcCallMode.Streaming, Endpoint = host.NativeEndpoint.AbsoluteUri }; + private static long HttpCount(GrpcSocketDaemonFixture host, string method) + => host.Snapshot().Where(x => x.Key.Method.EndsWith("/" + method, StringComparison.Ordinal)).Sum(x => x.Value); + private static GrpcSocketDaemonFixture Host(Control control) => new(configureServices: services => + { + var original = services.Last(s => s.ServiceType == typeof(ICSharpDbClient)); + services.Replace(ServiceDescriptor.Singleton(provider => + { + var proxy = DispatchProxy.Create(); + ((Backend)(object)proxy).Inner = (ICSharpDbClient)original.ImplementationFactory!(provider); + ((Backend)(object)proxy).Control = control; + return proxy; + })); + services.AddSingleton(new Capture(control)); + services.AddGrpc(options => options.Interceptors.Add()); + }); + public sealed class Control + { + public SqlExecutionResult Result = null!; + public string? Mode; + public int SqlCalls, Results; + public ConcurrentQueue Responses { get; } = new(); + } + public class Backend : DispatchProxy + { + public ICSharpDbClient Inner = null!; + public Control Control = null!; + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + if (targetMethod!.Name == nameof(ICSharpDbClient.ExecuteSqlAsync)) + { Interlocked.Increment(ref Control.SqlCalls); return Task.FromResult(Volatile.Read(ref Control.Result)); } + try { return targetMethod.Invoke(Inner, args); } + catch (TargetInvocationException error) when (error.InnerException is not null) + { ExceptionDispatchInfo.Capture(error.InnerException).Throw(); throw; } + } + } + public sealed class Capture(Control control) : Interceptor + { + public override Task DuplexStreamingServerHandler(IAsyncStreamReader reader, + IServerStreamWriter writer, ServerCallContext context, DuplexStreamingServerMethod continuation) + => continuation(new Reader(reader, control), new Writer(writer, control), context); + private sealed class Reader(IAsyncStreamReader inner, Control state) : IAsyncStreamReader + { + public T Current { get; private set; } = default!; + public async Task MoveNext(CancellationToken ct) + { + if (!await inner.MoveNext(ct)) return false; + Current = inner.Current; + if (Current is CommandSessionRequest { Hello: not null } request && state.Mode is "no-echo" or "unnegotiated") + { + request = request.Clone(); request.Hello.Capabilities.Remove(ScalarCapability); Current = (T)(object)request; + } + return true; + } + } + private sealed class Writer(IServerStreamWriter inner, Control state) : IServerStreamWriter + { + public WriteOptions? WriteOptions { get => inner.WriteOptions; set => inner.WriteOptions = value; } + public Task WriteAsync(T message) + { + if (message is not CommandSessionResponse response) return inner.WriteAsync(message); + response = response.Clone(); + if (response.Result is not null) + { + int number = Interlocked.Increment(ref state.Results); + if (state.Mode != "no-echo" && (number == 1 && state.Mode != "reference-width" || number == 2 && state.Mode == "reference-width")) + { + switch (state.Mode) + { + case "mixed-empty": response.Result.Rows = new VariantArrayList(); break; + case "mixed-filled": + response.Result.Rows = new VariantArrayList(); + response.Result.Rows.Items.Add(new VariantArray { Items = { new VariantValue { Int64Value = 42 } } }); break; + case "unnegotiated": response.Result.ScalarInt64 = 42; response.Result.Rows = null; break; + case "bad-width": response.Result.ColumnNames.Values.Add("other"); break; + case "reference-width": response.Result.ScalarInt64 = 42; response.Result.Rows = null; break; + case "nonquery": response.Result.IsQuery = false; response.ResultSchemaId = 0; response.ResultSchemaReference = false; break; + case "error": response.Result.Error = "malicious scalar error"; response.ResultSchemaId = 0; response.ResultSchemaReference = false; break; + case "missing-metadata": + response.Result.ColumnNames = null; response.Result.ColumnTypes = null; + response.Result.ColumnNullability = null; response.Result.Columns.Clear(); break; + } + } + } + state.Responses.Enqueue(response.Clone()); + return inner.WriteAsync((T)(object)response); + } + } + } + private sealed class RawSession(GrpcChannel channel, AsyncDuplexStreamingCall call, + CommandSessionReady ready) : IAsyncDisposable + { + private ulong _id; + public CommandSessionReady Ready { get; } = ready; + public static async Task OpenAsync(GrpcSocketDaemonFixture host, bool schema, bool scalar, CancellationToken ct) + { + var channel = GrpcChannel.ForAddress(host.NativeEndpoint); + var call = new CSharpDbRpc.CSharpDbRpcClient(channel).CommandSession(cancellationToken: ct); + var hello = new CommandSessionHello { ProtocolVersion = 1 }; + if (schema) hello.Capabilities.Add(SchemaCapability); + if (scalar) hello.Capabilities.Add(ScalarCapability); + try + { + await call.RequestStream.WriteAsync(new() { Hello = hello }, ct); + Assert.True(await call.ResponseStream.MoveNext(ct)); + return new(channel, call, call.ResponseStream.Current.Ready); + } + catch { call.Dispose(); channel.Dispose(); throw; } + } + public async Task QueryAsync(CancellationToken ct) + { + await call.RequestStream.WriteAsync(new() { RequestId = ++_id, ExecuteSql = new() { Sql = "SELECT controlled_scalar" } }, ct); + Assert.True(await call.ResponseStream.MoveNext(ct)); + Assert.Equal(_id, call.ResponseStream.Current.RequestId); + return call.ResponseStream.Current; + } + public async ValueTask DisposeAsync() + { + try + { + await call.RequestStream.CompleteAsync().WaitAsync(TimeSpan.FromSeconds(5)); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + Assert.False(await call.ResponseStream.MoveNext(timeout.Token)); + } + finally { call.Dispose(); channel.Dispose(); } + } + } +} diff --git a/tests/CSharpDB.Daemon.Tests/GrpcHandlerLifetimeTests.cs b/tests/CSharpDB.Daemon.Tests/GrpcHandlerLifetimeTests.cs new file mode 100644 index 00000000..253d433f --- /dev/null +++ b/tests/CSharpDB.Daemon.Tests/GrpcHandlerLifetimeTests.cs @@ -0,0 +1,150 @@ +using System.Net; +using CSharpDB.Client; +using CSharpDB.Testing; +using Grpc.Net.Client.Web; + +namespace CSharpDB.Daemon.Tests; + +public sealed class GrpcHandlerLifetimeTests : IAsyncLifetime +{ + private const string ApiKey = "handler-lifetime-test-key"; + private GrpcSocketDaemonFixture _host = null!; + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + public ValueTask InitializeAsync() + { + _host = new GrpcSocketDaemonFixture(ApiKey); + return ValueTask.CompletedTask; + } + + public async ValueTask DisposeAsync() + { + await CSharpDbClient.ClearPoolAsync(_host.CompatibilityEndpoint.AbsoluteUri); + await CSharpDbClient.ClearPoolAsync(_host.NativeEndpoint.AbsoluteUri); + await _host.DisposeAsync(); + } + + private CSharpDbClientOptions Options(CSharpDbGrpcMode mode, string apiKey = ApiKey, HttpClient? httpClient = null) => new() + { + Transport = CSharpDbTransport.Grpc, + GrpcMode = mode, + Endpoint = _host.EndpointFor(mode).AbsoluteUri, + ApiKey = apiKey, + HttpClient = httpClient, + }; + + [Theory] + [InlineData(CSharpDbGrpcMode.Auto)] + [InlineData(CSharpDbGrpcMode.Native)] + public async Task DirectDispose_ClosesEstablishedTransport(CSharpDbGrpcMode mode) + { + var client = CSharpDbClient.Create(Options(mode)); + try + { + Assert.NotNull(await client.GetInfoAsync(Ct)); + Task closed = Assert.Single(_host.ConnectionClosures); + Assert.False(closed.IsCompleted); + await client.DisposeAsync(); + await closed.WaitAsync(TimeSpan.FromSeconds(3), Ct); + } + finally { await client.DisposeAsync(); } + } + + [Theory] + [InlineData(CSharpDbGrpcMode.Auto)] + [InlineData(CSharpDbGrpcMode.Native)] + public async Task PoolClear_ClosesReturnedTransport(CSharpDbGrpcMode mode) + { + var options = Options(mode); + await using (var client = await CSharpDbClient.RentAsync(options, 1, Ct)) { } + Task closed = Assert.Single(_host.ConnectionClosures); + Assert.False(closed.IsCompleted); + await CSharpDbClient.ClearPoolAsync(options.Endpoint!); + await closed.WaitAsync(TimeSpan.FromSeconds(3), Ct); + } + + [Theory] + [InlineData(CSharpDbGrpcMode.Auto)] + [InlineData(CSharpDbGrpcMode.Native)] + public async Task PoolClear_PreservesActiveTransport_UntilRentalReturns(CSharpDbGrpcMode mode) + { + var options = Options(mode); + await using var client = await CSharpDbClient.RentAsync(options, 1, Ct); + Task closed = Assert.Single(_host.ConnectionClosures); + await CSharpDbClient.ClearPoolAsync(options.Endpoint!); + Assert.False(closed.IsCompleted); + Assert.NotNull(await client.GetInfoAsync(Ct)); + await client.DisposeAsync(); + await closed.WaitAsync(TimeSpan.FromSeconds(3), Ct); + } + + [Theory] + [InlineData(CSharpDbGrpcMode.Auto)] + [InlineData(CSharpDbGrpcMode.Native)] + public async Task FailedWarmup_ClosesRejectedTransport(CSharpDbGrpcMode mode) + { + await Assert.ThrowsAsync(() => + CSharpDbClient.RentAsync(Options(mode, "wrong-key"), 1, Ct).AsTask()); + await Assert.Single(_host.ConnectionClosures).WaitAsync(TimeSpan.FromSeconds(3), Ct); + } + + [Theory] + [InlineData(CSharpDbGrpcMode.Auto)] + [InlineData(CSharpDbGrpcMode.Native)] + public async Task FaultedRental_ClosesDiscardedTransport(CSharpDbGrpcMode mode) + { + await using var client = await CSharpDbClient.RentAsync(Options(mode), 1, Ct); + Task closed = Assert.Single(_host.ConnectionClosures); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => client.GetInfoAsync(cancellation.Token)); + await client.DisposeAsync(); + await closed.WaitAsync(TimeSpan.FromSeconds(3), Ct); + } + + [Theory] + [InlineData(CSharpDbGrpcMode.Auto, false)] + [InlineData(CSharpDbGrpcMode.Auto, true)] + [InlineData(CSharpDbGrpcMode.Native, false)] + [InlineData(CSharpDbGrpcMode.Native, true)] + public async Task CallerHttpClient_RemainsUsable_AfterClientDisposeAndPoolClear(CSharpDbGrpcMode mode, bool pooled) + { + HttpMessageHandler transport = mode == CSharpDbGrpcMode.Auto + ? new CompatibilityVersionHandler() + : new SocketsHttpHandler(); + using var httpClient = new HttpClient(transport); + var options = Options(mode, httpClient: httpClient); + var client = pooled + ? await CSharpDbClient.RentAsync(options, 1, Ct) + : CSharpDbClient.Create(options); + try + { + Assert.NotNull(await client.GetInfoAsync(Ct)); + await client.DisposeAsync(); + await CSharpDbClient.ClearPoolAsync(options.Endpoint!); + Task closed = Assert.Single(_host.ConnectionClosures); + Assert.False(closed.IsCompleted); + using var request = new HttpRequestMessage(HttpMethod.Get, _host.EndpointFor(mode)) + { + Version = mode == CSharpDbGrpcMode.Auto ? HttpVersion.Version11 : HttpVersion.Version20, + VersionPolicy = HttpVersionPolicy.RequestVersionExact, + }; + using var response = await httpClient.SendAsync(request, Ct); + Assert.Equal(request.Version, response.Version); + httpClient.Dispose(); + await closed.WaitAsync(TimeSpan.FromSeconds(3), Ct); + } + finally { await client.DisposeAsync(); } + } + + private sealed class CompatibilityVersionHandler() + : DelegatingHandler(new GrpcWebHandler(GrpcWebMode.GrpcWeb, new SocketsHttpHandler())) + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + request.Version = HttpVersion.Version11; + request.VersionPolicy = HttpVersionPolicy.RequestVersionExact; + return base.SendAsync(request, ct); + } + } +} diff --git a/tests/CSharpDB.Daemon.Tests/GrpcMappedSchemaTests.cs b/tests/CSharpDB.Daemon.Tests/GrpcMappedSchemaTests.cs new file mode 100644 index 00000000..6e9150a7 --- /dev/null +++ b/tests/CSharpDB.Daemon.Tests/GrpcMappedSchemaTests.cs @@ -0,0 +1,403 @@ +using System.Collections.Concurrent; +using System.Reflection; +using CSharpDB.Client; +using CSharpDB.Client.Grpc; +using CSharpDB.Client.Internal; +using CSharpDB.Client.Models; +using CSharpDB.Testing; +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; +using Microsoft.Extensions.DependencyInjection; +using ModelColumn = CSharpDB.Client.Models.ColumnDefinition; +using ModelType = CSharpDB.Client.Models.DbType; +using ModelSqlKind = CSharpDB.Client.Models.SqlTypeKind; + +namespace CSharpDB.Daemon.Tests; + +public sealed class GrpcMappedSchemaTests +{ + [Theory(Timeout = 30000)] + [InlineData(false)] + [InlineData(true)] + public async Task DelayedReferenceMapping_UsesItsCapturedSchema_AfterNewProjectionAndCacheClear(bool compact) + { + var ct = TestContext.Current.CancellationToken; + var oldDefinition = Definition("old_value"); + var newDefinition = Definition("new_value"); + newDefinition.Columns[0].DeclaredType.Kind = SqlTypeKindEnum.SqlTypeKindVarChar; + newDefinition.Columns[0].DeclaredType.Length = 73; + var delayedFrame = Reference(1, 42); + if (compact) { delayedFrame.Result.Rows = null; delayedFrame.Result.ScalarInt64 = 42; } + var peer = new ScriptedPeer( + Full(1, oldDefinition), delayedFrame, Full(2, newDefinition), + new CommandSessionResponse { Info = new DatabaseInfoMessage { DataSource = "cleared" } }); + await using var session = new GrpcCommandSession(new ScriptedInvoker(peer)); + _ = await session.ExecuteSqlAsync(Query(), ct); + var delayed = await session.ExecuteSqlAsync(Query(), ct); + AssertRawMetadataAbsent(delayed.Response); + Assert.NotNull(delayed.Metadata); + var newer = await session.ExecuteSqlAsync(Query(), ct); + Assert.Equal("new_value", Assert.Single(GrpcModelMapper.ToSessionModel(newer.Response, newer.Metadata).ColumnNames!)); + _ = await session.ExecuteAsync(new() { GetInfo = new Empty() }, ct); + + // Mapping is deliberately delayed until both the replacement and clear have + // completed. It must not consult the session's current schema at this point. + var mapped = GrpcModelMapper.ToSessionModel(delayed.Response, delayed.Metadata); + AssertMetadataEqual(GrpcModelMapper.ToModel(oldDefinition), mapped); + Assert.Equal(42L, Assert.Single(Assert.Single(mapped.Rows!))); + AssertRawMetadataAbsent(delayed.Response); + Assert.Equal(3, peer.SqlWrites); + } + + [Fact(Timeout = 30000)] + public async Task CanonicalSchema_IsPrivate_FromWireMutationAndEveryPublicMetadataObject() + { + var ct = TestContext.Current.CancellationToken; + var definition = Definition("safe_value"); + var expected = GrpcModelMapper.ToModel(definition.Clone()); + var peer = new ScriptedPeer(Full(1, definition), Reference(1, 2), Reference(1, 3)); + await using var session = new GrpcCommandSession(new ScriptedInvoker(peer)); + var firstReply = await session.ExecuteSqlAsync(Query(), ct); + var first = GrpcModelMapper.ToSessionModel(firstReply.Response, firstReply.Metadata); + var secondReply = await session.ExecuteSqlAsync(Query(), ct); + var second = GrpcModelMapper.ToSessionModel(secondReply.Response, secondReply.Metadata); + AssertFreshMetadata(first, second); + + // The received full protobuf and public init-only DTOs are both mutable + // through ordinary reflection. Neither is the canonical cache storage. + firstReply.Response.ColumnNames.Values[0] = "wire_poison"; + firstReply.Response.Columns[0].Name = "wire_poison"; + firstReply.Response.Columns[0].DeclaredType.Length = 999; + Poison(first); + AssertMetadataEqual(expected, second); + var thirdReply = await session.ExecuteSqlAsync(Query(), ct); + var third = GrpcModelMapper.ToSessionModel(thirdReply.Response, thirdReply.Metadata); + AssertMetadataEqual(expected, third); + AssertFreshMetadata(second, third); + AssertRawMetadataAbsent(thirdReply.Response); + Assert.Equal(3, peer.SqlWrites); + } + + [Theory(Timeout = 30000)] + [InlineData("absent")] + [InlineData("empty")] + [InlineData("mixed")] + [InlineData("legacy-column")] + [InlineData("all-facets")] + public async Task FullAndReferencedMetadata_PreserveCanonicalMappingExactly(string shape) + { + var ct = TestContext.Current.CancellationToken; + var definition = Definition("value"); + switch (shape) + { + case "absent": + definition.ColumnNames = null; definition.ColumnTypes = null; + definition.ColumnNullability = null; definition.Columns.Clear(); break; + case "empty": + definition.ColumnNames = new StringList(); definition.ColumnTypes = new StringList(); + definition.ColumnNullability = new BoolList(); definition.Columns.Clear(); break; + case "mixed": + definition.ColumnNames = new StringList(); definition.ColumnTypes = null; + definition.ColumnNullability = new BoolList(); definition.Columns.Clear(); break; + case "legacy-column": definition.Columns[0].DeclaredType = null; break; + case "all-facets": + // Existing mapping copies facets without applying additional SQL + // validation. The cache must preserve that same wire contract. + definition.Columns[0].DeclaredType = new() + { + Kind = SqlTypeKindEnum.SqlTypeKindDecimal, Length = -7, + Precision = 31, Scale = 12, FractionalSecondsPrecision = 8, + }; + break; + } + var expected = GrpcModelMapper.ToModel(definition.Clone()); + var peer = new ScriptedPeer(Full(1, definition), Reference(1, 7)); + await using var session = new GrpcCommandSession(new ScriptedInvoker(peer)); + var first = await session.ExecuteSqlAsync(Query(), ct); + var repeat = await session.ExecuteSqlAsync(Query(), ct); + var firstModel = GrpcModelMapper.ToSessionModel(first.Response, first.Metadata); + var repeatedModel = GrpcModelMapper.ToSessionModel(repeat.Response, repeat.Metadata); + AssertMetadataEqual(expected, firstModel); + AssertMetadataEqual(expected, repeatedModel); + AssertRawMetadataAbsent(repeat.Response); + if (shape == "legacy-column") + { + Assert.Null(Assert.Single(repeatedModel.Columns!).DeclaredType); + Assert.Equal(ModelSqlKind.BigInt, repeatedModel.Columns![0].EffectiveType.Kind); + } + if (firstModel.ColumnNames is not null) Assert.NotSame(firstModel.ColumnNames, repeatedModel.ColumnNames); + if (firstModel.ColumnTypes is not null) Assert.NotSame(firstModel.ColumnTypes, repeatedModel.ColumnTypes); + if (firstModel.ColumnNullability is not null) Assert.NotSame(firstModel.ColumnNullability, repeatedModel.ColumnNullability); + } + + [Fact(Timeout = 30000)] + public async Task GenericWireApi_RestoresMetadata_WithoutExposingCanonicalMappedObjects() + { + var ct = TestContext.Current.CancellationToken; + var definition = Definition("generic_value"); + var expected = GrpcModelMapper.ToModel(definition.Clone()); + var peer = new ScriptedPeer(Full(1, definition), Reference(1, 2), Reference(1, 3)); + await using var session = new GrpcCommandSession(new ScriptedInvoker(peer)); + // Exercise lazy canonical construction too: both initial commands use + // the generic wire API before any direct SQL mapping is requested. + _ = await session.ExecuteAsync(Query(), ct); + var generic = await session.ExecuteAsync(Query(), ct); + Assert.NotNull(generic.ColumnNames); + Assert.Single(generic.Columns); + AssertMetadataEqual(expected, GrpcModelMapper.ToModel(generic)); + generic.ColumnNames!.Values[0] = "generic_poison"; + generic.Columns[0].Name = "generic_poison"; + generic.Columns[0].DeclaredType.Length = 999; + var direct = await session.ExecuteSqlAsync(Query(), ct); + AssertRawMetadataAbsent(direct.Response); + AssertMetadataEqual(expected, GrpcModelMapper.ToSessionModel(direct.Response, direct.Metadata)); + Assert.Equal(3, peer.SqlWrites); + } + + [Theory(Timeout = 30000)] + [InlineData("guid")] + [InlineData("physical-enum")] + [InlineData("declared-enum")] + public async Task InvalidNewFullMetadata_PreservesCanonicalException_ClosesSessionWithoutReplay(string invalid) + { + var ct = TestContext.Current.CancellationToken; + var malformed = Definition("bad_value"); + switch (invalid) + { + case "guid": malformed.Columns[0].SchemaId = "not-a-guid"; break; + case "physical-enum": malformed.Columns[0].Type = (DbTypeEnum)999; break; + case "declared-enum": malformed.Columns[0].DeclaredType.Kind = (SqlTypeKindEnum)999; break; + } + var expected = Record.Exception(() => GrpcModelMapper.ToModel(malformed.Clone())); + Assert.NotNull(expected); + Assert.Equal(invalid == "guid" ? typeof(CSharpDbClientException) : typeof(ArgumentOutOfRangeException), expected.GetType()); + var peer = new ScriptedPeer(Full(1, Definition("good_value")), Reference(1, 2), Full(2, malformed)); + await using var session = new GrpcCommandSession(new ScriptedInvoker(peer)); + _ = await session.ExecuteSqlAsync(Query(), ct); + _ = await session.ExecuteSqlAsync(Query(), ct); + var actual = await Record.ExceptionAsync(async () => { _ = await session.ExecuteSqlAsync(Query(), ct); }); + Assert.NotNull(actual); + Assert.Equal(expected.GetType(), actual.GetType()); + Assert.Equal(expected.Message, actual.Message); + var closed = await Assert.ThrowsAsync(async () => { _ = await session.ExecuteSqlAsync(Query(), ct); }); + Assert.Equal(StatusCode.Unavailable, closed.StatusCode); + Assert.Equal(3, peer.SqlWrites); + Assert.Equal(1, peer.CallStarts); + Assert.True(peer.Disposals > 0); + } + + [Fact(Timeout = 30000)] + public async Task CacheClear_RequiresNewDefinition_AndNewSessionStartsWithItsOwnSchema() + { + var ct = TestContext.Current.CancellationToken; + var peer = new ScriptedPeer(Full(1, Definition("before_clear")), + new CommandSessionResponse { Info = new DatabaseInfoMessage() }, Reference(1, 9)); + await using (var session = new GrpcCommandSession(new ScriptedInvoker(peer))) + { + _ = await session.ExecuteSqlAsync(Query(), ct); + _ = await session.ExecuteAsync(new() { GetInfo = new Empty() }, ct); + var error = await Assert.ThrowsAsync(async () => { _ = await session.ExecuteSqlAsync(Query(), ct); }); + Assert.Equal(StatusCode.DataLoss, error.StatusCode); + } + var reconnected = new ScriptedPeer(Full(1, Definition("after_reconnect")), Reference(1, 4)); + await using var fresh = new GrpcCommandSession(new ScriptedInvoker(reconnected)); + _ = await fresh.ExecuteSqlAsync(Query(), ct); + var response = await fresh.ExecuteSqlAsync(Query(), ct); + Assert.Equal("after_reconnect", Assert.Single(GrpcModelMapper.ToSessionModel(response.Response, response.Metadata).ColumnNames!)); + Assert.Equal(1, peer.CallStarts); + Assert.Equal(1, reconnected.CallStarts); + } + + [Fact(Timeout = 30000)] + public async Task SocketPoolReuseAndReconnect_DoNotRetainCallerReflectionMutations() + { + var ct = TestContext.Current.CancellationToken; + var capture = new GrpcResultSchemaReuseTests.SessionInterceptor(); + await using var host = new GrpcSocketDaemonFixture(configureServices: services => + { + services.AddSingleton(capture); + services.AddGrpc(options => options.Interceptors.Add()); + }); + var options = new CSharpDbClientOptions + { + Transport = CSharpDbTransport.Grpc, GrpcMode = CSharpDbGrpcMode.Native, + GrpcCallMode = CSharpDbGrpcCallMode.Streaming, Endpoint = host.NativeEndpoint.AbsoluteUri, + }; + const string sql = "SELECT CAST(42 AS INTEGER) AS mapped_value"; + try + { + SqlExecutionResult first, expected; + await using (var rental = await CSharpDbClient.RentAsync(options, 1, ct)) + { + first = await rental.ExecuteSqlAsync(sql, ct); + expected = await rental.ExecuteSqlAsync(sql, ct); + AssertFreshMetadata(first, expected); + Poison(first); + } + await using (var rental = await CSharpDbClient.RentAsync(options, 1, ct)) + { + var reused = await rental.ExecuteSqlAsync(sql, ct); + AssertMetadataEqual(expected, reused); + AssertFreshMetadata(expected, reused); + Assert.Equal(42L, Assert.Single(Assert.Single(reused.Rows!))); + } + var beforeReconnect = capture.Responses.Where(r => r.Result is not null).ToArray(); + Assert.Equal(3, beforeReconnect.Length); + Assert.False(beforeReconnect[0].ResultSchemaReference); + Assert.All(beforeReconnect.Skip(1), response => Assert.True(response.ResultSchemaReference)); + await CSharpDbClient.ClearPoolAsync(options.Endpoint!); + await using var reconnected = await CSharpDbClient.RentAsync(options, 1, ct); + var fresh = await reconnected.ExecuteSqlAsync(sql, ct); + AssertMetadataEqual(expected, fresh); + AssertFreshMetadata(expected, fresh); + var last = capture.Responses.Last(r => r.Result is not null); + Assert.False(last.ResultSchemaReference); + Assert.Equal(1UL, last.ResultSchemaId); + } + finally { await CSharpDbClient.ClearPoolAsync(options.Endpoint!); } + } + + private static void Poison(SqlExecutionResult result) + { + var column = result.Columns![0]; + typeof(ModelColumn).GetProperty(nameof(ModelColumn.Name))!.SetValue(column, "reflection_poison"); + typeof(ModelColumn).GetProperty(nameof(ModelColumn.SchemaId))!.SetValue(column, Guid.NewGuid()); + typeof(SqlTypeDescriptor).GetProperty(nameof(SqlTypeDescriptor.Kind))!.SetValue(column.DeclaredType, ModelSqlKind.Blob); + typeof(SqlTypeDescriptor).GetProperty(nameof(SqlTypeDescriptor.Length))!.SetValue(column.DeclaredType, 999); + result.ColumnNames![0] = "array_poison"; + result.ColumnTypes![0] = "BLOB"; + result.ColumnNullability![0] = !result.ColumnNullability[0]; + result.Columns[0] = new ModelColumn { Name = "replacement", Type = ModelType.Blob }; + } + + private static void AssertFreshMetadata(SqlExecutionResult first, SqlExecutionResult second) + { + Assert.NotSame(first.ColumnNames, second.ColumnNames); + Assert.NotSame(first.ColumnTypes, second.ColumnTypes); + Assert.NotSame(first.ColumnNullability, second.ColumnNullability); + Assert.NotSame(first.Columns, second.Columns); + Assert.NotSame(first.Columns![0], second.Columns![0]); + Assert.NotSame(first.Columns[0].DeclaredType, second.Columns[0].DeclaredType); + } + + private static void AssertMetadataEqual(SqlExecutionResult expected, SqlExecutionResult actual) + { + Assert.Equal(expected.ColumnNames, actual.ColumnNames); + Assert.Equal(expected.ColumnTypes, actual.ColumnTypes); + Assert.Equal(expected.ColumnNullability, actual.ColumnNullability); + if (expected.Columns is null) { Assert.Null(actual.Columns); return; } + Assert.NotNull(actual.Columns); + Assert.Equal(expected.Columns.Length, actual.Columns.Length); + for (int i = 0; i < expected.Columns.Length; i++) + { + var wanted = expected.Columns[i]; var got = actual.Columns[i]; + Assert.Equal(wanted.SchemaId, got.SchemaId); Assert.Equal(wanted.Name, got.Name); + Assert.Equal(wanted.Type, got.Type); Assert.Equal(wanted.Nullable, got.Nullable); + Assert.Equal(wanted.IsPrimaryKey, got.IsPrimaryKey); Assert.Equal(wanted.IsIdentity, got.IsIdentity); + Assert.Equal(wanted.IsRowVersion, got.IsRowVersion); Assert.Equal(wanted.Collation, got.Collation); + Assert.Equal(wanted.DefaultSql, got.DefaultSql); + if (wanted.DeclaredType is null) { Assert.Null(got.DeclaredType); continue; } + Assert.NotNull(got.DeclaredType); + Assert.Equal(wanted.DeclaredType.Kind, got.DeclaredType.Kind); + Assert.Equal(wanted.DeclaredType.Length, got.DeclaredType.Length); + Assert.Equal(wanted.DeclaredType.Precision, got.DeclaredType.Precision); + Assert.Equal(wanted.DeclaredType.Scale, got.DeclaredType.Scale); + Assert.Equal(wanted.DeclaredType.FractionalSecondsPrecision, got.DeclaredType.FractionalSecondsPrecision); + } + } + + private static void AssertRawMetadataAbsent(SqlExecutionResultMessage result) + { + Assert.Null(result.ColumnNames); Assert.Null(result.ColumnTypes); + Assert.Null(result.ColumnNullability); Assert.Empty(result.Columns); + } + + private static CommandSessionRequest Query() => new() { ExecuteSql = new() { Sql = "SELECT scripted_metadata" } }; + private static CommandSessionResponse Full(ulong id, SqlExecutionResultMessage result) => new() { ResultSchemaId = id, Result = result }; + private static CommandSessionResponse Reference(ulong id, long value) => new() + { + ResultSchemaId = id, ResultSchemaReference = true, + Result = new SqlExecutionResultMessage + { + IsQuery = true, Elapsed = new Duration(), Rows = GrpcModelMapper.ToVariantArrayList([new object?[] { value }]), + }, + }; + + private static SqlExecutionResultMessage Definition(string name) + { + var result = new SqlExecutionResultMessage + { + IsQuery = true, Elapsed = new Duration(), ColumnNames = GrpcModelMapper.ToStringList([name]), + ColumnTypes = GrpcModelMapper.ToStringList(["BIGINT"]), ColumnNullability = GrpcModelMapper.ToBoolList([true]), + Rows = GrpcModelMapper.ToVariantArrayList([new object?[] { 1L }]), + }; + result.Columns.Add(new ColumnDefinitionMessage + { + SchemaId = "12345678-1234-5678-9012-123456789012", Name = name, Type = DbTypeEnum.DbTypeInteger, + Nullable = true, IsPrimaryKey = true, IsIdentity = true, IsRowVersion = true, + Collation = "NOCASE", DefaultSql = "42", + DeclaredType = new() { Kind = SqlTypeKindEnum.SqlTypeKindBigInt, Length = 23 }, + }); + return result; + } + + private sealed class ScriptedPeer(params CommandSessionResponse[] responses) + { + private readonly Queue _script = new(responses); + private readonly ConcurrentQueue _received = new(); + public int SqlWrites, CallStarts, Disposals; + public bool Completed; + public void Write(CommandSessionRequest request) + { + if (request.Hello is not null) + { + _received.Enqueue(new() { Ready = new() { ProtocolVersion = 1, MaxInFlight = 1, + Capabilities = { "sql", "transactions", "result-schema-ref-v1", "single-int64-result-v1" } } }); + return; + } + if (request.ExecuteSql is not null) SqlWrites++; + var response = _script.Dequeue(); + response.RequestId = request.RequestId; + _received.Enqueue(response); + } + public bool Read(out CommandSessionResponse response) => _received.TryDequeue(out response!); + } + + private sealed class ScriptedInvoker(ScriptedPeer peer) : CallInvoker + { + public override AsyncDuplexStreamingCall AsyncDuplexStreamingCall( + Method method, string? host, CallOptions options) + { + peer.CallStarts++; + return new((IClientStreamWriter)(object)new Writer(peer), + (IAsyncStreamReader)(object)new Reader(peer), Task.FromResult(new Metadata()), + () => Status.DefaultSuccess, () => new Metadata(), () => peer.Disposals++); + } + public override TResponse BlockingUnaryCall(Method method, string? host, CallOptions options, TRequest request) => throw new NotSupportedException(); + public override AsyncUnaryCall AsyncUnaryCall(Method method, string? host, CallOptions options, TRequest request) => throw new NotSupportedException(); + public override AsyncServerStreamingCall AsyncServerStreamingCall(Method method, string? host, CallOptions options, TRequest request) => throw new NotSupportedException(); + public override AsyncClientStreamingCall AsyncClientStreamingCall(Method method, string? host, CallOptions options) => throw new NotSupportedException(); + private sealed class Writer(ScriptedPeer state) : IClientStreamWriter + { + public WriteOptions? WriteOptions { get; set; } + public Task WriteAsync(CommandSessionRequest message) { state.Write(message); return Task.CompletedTask; } + public Task WriteAsync(CommandSessionRequest message, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + return WriteAsync(message); + } + public Task CompleteAsync() { state.Completed = true; return Task.CompletedTask; } + } + private sealed class Reader(ScriptedPeer state) : IAsyncStreamReader + { + public CommandSessionResponse Current { get; private set; } = null!; + public Task MoveNext(CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (state.Read(out var next)) { Current = next; return Task.FromResult(true); } + if (!state.Completed) throw new InvalidOperationException("The scripted response was not queued by its request."); + return Task.FromResult(false); + } + } + } +} diff --git a/tests/CSharpDB.Daemon.Tests/GrpcPrivateScalarProjectionTests.cs b/tests/CSharpDB.Daemon.Tests/GrpcPrivateScalarProjectionTests.cs new file mode 100644 index 00000000..949532e4 --- /dev/null +++ b/tests/CSharpDB.Daemon.Tests/GrpcPrivateScalarProjectionTests.cs @@ -0,0 +1,692 @@ +using System.Collections.Concurrent; +using System.Reflection; +using CSharpDB.Client; +using CSharpDB.Client.Grpc; +using CSharpDB.Client.Internal; +using CSharpDB.Client.Models; +using CSharpDB.Testing; +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; +using Grpc.Core.Interceptors; +using Microsoft.Extensions.DependencyInjection; +using ModelColumn = CSharpDB.Client.Models.ColumnDefinition; +using ModelKind = CSharpDB.Client.Models.SqlTypeKind; +using ModelType = CSharpDB.Client.Models.DbType; + +namespace CSharpDB.Daemon.Tests; + +public sealed class GrpcPrivateScalarProjectionTests +{ + [Theory] + [InlineData(0L, "integer")] + [InlineData(-1L, "bigint")] + [InlineData(long.MinValue, "none")] + [InlineData(long.MaxValue, "facets")] + public void CompactProjection_PreservesCarrierAndEveryDeclaredFacet(long value, string declared) + { + var definition = Definition("value"); + if (declared == "none") definition.Columns[0].DeclaredType = null; + else if (declared == "bigint") + definition.Columns[0].DeclaredType.Kind = SqlTypeKindEnum.SqlTypeKindBigInt; + else if (declared == "facets") + { + // SDK metadata mapping copies these values; ADO performs its existing + // facet validation later. Projection must neither normalize nor drop them. + definition.Columns[0].DeclaredType = new() + { + Kind = SqlTypeKindEnum.SqlTypeKindDecimal, Length = -7, + Precision = 31, Scale = 12, FractionalSecondsPrecision = 8, + }; + } + var metadata = new GrpcSessionResultMetadata(definition); + var response = Compact(value); + var projected = GrpcTransportClient.MapScalarProjectionReply(new(response, metadata)); + var sdk = GrpcModelMapper.ToSessionModel(response, metadata); + + Assert.True(projected.HasCompactInt64); + Assert.Equal(value, projected.Int64); + Assert.Null(projected.Fallback); + Assert.Equal(value, Assert.IsType(Assert.Single(Assert.Single(sdk.Rows!)))); + var expectedType = sdk.Columns![0].DeclaredType; + if (expectedType is null) Assert.Null(projected.DeclaredType); + else + { + ScalarTypeSnapshot actualType = Assert.IsType(projected.DeclaredType); + Assert.Equal(new ScalarTypeSnapshot(expectedType.Kind, expectedType.Length, + expectedType.Precision, expectedType.Scale, expectedType.FractionalSecondsPrecision), actualType); + } + } + + [Theory] + [InlineData("ordinary-long")] + [InlineData("null")] + [InlineData("no-rows")] + [InlineData("wide")] + [InlineData("nonquery")] + [InlineData("error")] + [InlineData("uncached-full")] + [InlineData("names-only")] + [InlineData("columns-only")] + public void UnsupportedProjection_MapsTheAlreadyReceivedReply_WithSdkSemantics(string shape) + { + var definition = Definition("value"); + var response = Compact(42); + bool capture = true; + switch (shape) + { + case "ordinary-long": response.ClearScalarInt64(); response.Rows = Rows(42L); break; + case "null": response.ClearScalarInt64(); response.Rows = Rows(null); break; + case "no-rows": response.ClearScalarInt64(); response.Rows = new VariantArrayList(); break; + case "wide": + definition.ColumnNames = GrpcModelMapper.ToStringList(["value", "ignored"]); + definition.ColumnTypes = GrpcModelMapper.ToStringList(["INTEGER", "TEXT"]); + definition.ColumnNullability = GrpcModelMapper.ToBoolList([true, true]); + definition.Columns.Add(new ColumnDefinitionMessage + { + Name = "ignored", Type = DbTypeEnum.DbTypeText, + DeclaredType = new() { Kind = SqlTypeKindEnum.SqlTypeKindText }, + }); + response.ClearScalarInt64(); + response.Rows = GrpcModelMapper.ToVariantArrayList([new object?[] { 42L, "second" }]); + break; + case "nonquery": + response = new() { IsQuery = false, RowsAffected = 3, Elapsed = new Duration() }; + capture = false; + break; + case "error": + response = new() { IsQuery = false, Error = "SQL failed", Elapsed = new Duration() }; + capture = false; + break; + case "uncached-full": + response = definition.Clone(); response.Rows = null; response.ScalarInt64 = 42; + capture = false; + break; + case "names-only": definition.Columns.Clear(); break; + case "columns-only": definition.ColumnNames = null; break; + } + GrpcSessionResultMetadata? metadata = capture ? new(definition) : null; + var expected = GrpcModelMapper.ToSessionModel(response, metadata); + var projected = GrpcTransportClient.MapScalarProjectionReply(new(response, metadata)); + + Assert.False(projected.HasCompactInt64); + Assert.Null(projected.DeclaredType); + Assert.NotNull(projected.Fallback); + AssertResultEqual(expected, projected.Fallback); + } + + [Theory] + [InlineData("compact-with-rows")] + [InlineData("compact-nonquery")] + [InlineData("compact-error")] + [InlineData("compact-width")] + [InlineData("compact-duration")] + [InlineData("ordinary-duration")] + [InlineData("missing-duration")] + public void MalformedReply_PreservesSdkException_IncludingDurationValidation(string malformed) + { + var definition = Definition("value"); + var response = Compact(42); + switch (malformed) + { + case "compact-with-rows": response.Rows = new VariantArrayList(); break; + case "compact-nonquery": response.IsQuery = false; break; + case "compact-error": response.Error = ""; break; + case "compact-width": + definition.ColumnNames = GrpcModelMapper.ToStringList(["value", "other"]); break; + case "compact-duration": response.Elapsed = new Duration { Seconds = 1, Nanos = -1 }; break; + case "ordinary-duration": + response.ClearScalarInt64(); response.Rows = Rows(42L); + response.Elapsed = new Duration { Seconds = 1, Nanos = -1 }; break; + case "missing-duration": response.Elapsed = null; break; + } + var metadata = new GrpcSessionResultMetadata(definition); + Exception? expected = Record.Exception(() => GrpcModelMapper.ToSessionModel(response, metadata)); + Exception? actual = Record.Exception(() => GrpcTransportClient.MapScalarProjectionReply(new(response, metadata))); + Assert.NotNull(expected); + Assert.NotNull(actual); + Assert.Equal(expected.GetType(), actual.GetType()); + Assert.Equal(expected.Message, actual.Message); + } + + [Fact(Timeout = 30000)] + public async Task DelayedPrivateProjection_UsesCapturedType_AfterPublicMutationNewSchemaAndClear() + { + var ct = TestContext.Current.CancellationToken; + var oldDefinition = Definition("old_value"); + var newDefinition = Definition("new_value"); + newDefinition.Columns[0].DeclaredType.Kind = SqlTypeKindEnum.SqlTypeKindBigInt; + var peer = new ScriptedPeer(Full(1, oldDefinition), Reference(1, 42), Reference(1, 43), + Full(2, newDefinition), new() { Info = new DatabaseInfoMessage() }); + await using var session = new GrpcCommandSession(new ScriptedInvoker(peer)); + var first = await session.ExecuteSqlAsync(Query(), ct); + SqlExecutionResult publicResult = GrpcModelMapper.ToSessionModel(first.Response, first.Metadata); + var delayed = await session.ExecuteSqlAsync(Query(), ct); + var repeated = await session.ExecuteSqlAsync(Query(), ct); + var newer = await session.ExecuteSqlAsync(Query(), ct); + _ = await session.ExecuteAsync(new() { GetInfo = new Empty() }, ct); + + publicResult.ColumnNames![0] = "caller_array"; + typeof(ModelColumn).GetProperty(nameof(ModelColumn.Name))!.SetValue(publicResult.Columns![0], "caller_column"); + SqlTypeDescriptor publicType = publicResult.Columns[0].DeclaredType!; + typeof(SqlTypeDescriptor).GetProperty(nameof(SqlTypeDescriptor.Kind))!.SetValue(publicType, ModelKind.Blob); + typeof(SqlTypeDescriptor).GetProperty(nameof(SqlTypeDescriptor.Length))!.SetValue(publicType, 99); + typeof(SqlTypeDescriptor).GetProperty(nameof(SqlTypeDescriptor.Precision))!.SetValue(publicType, 17); + typeof(SqlTypeDescriptor).GetProperty(nameof(SqlTypeDescriptor.Scale))!.SetValue(publicType, 4); + typeof(SqlTypeDescriptor).GetProperty(nameof(SqlTypeDescriptor.FractionalSecondsPrecision))!.SetValue(publicType, 6); + publicResult.Columns[0] = new() { Name = "replacement", Type = ModelType.Blob }; + first.Response.Columns[0].DeclaredType.Kind = SqlTypeKindEnum.SqlTypeKindText; + + var oldProjection = GrpcTransportClient.MapScalarProjectionReply(delayed); + var repeatedProjection = GrpcTransportClient.MapScalarProjectionReply(repeated); + var newProjection = GrpcTransportClient.MapScalarProjectionReply(newer); + Assert.True(oldProjection.HasCompactInt64); + Assert.Equal(42L, oldProjection.Int64); + Assert.Equal(new ScalarTypeSnapshot(ModelKind.Integer, null, null, null, null), oldProjection.DeclaredType); + Assert.True(repeatedProjection.HasCompactInt64); + Assert.Equal(43L, repeatedProjection.Int64); + Assert.Equal(new ScalarTypeSnapshot(ModelKind.Integer, null, null, null, null), repeatedProjection.DeclaredType); + Assert.True(newProjection.HasCompactInt64); + Assert.Equal(1L, newProjection.Int64); + Assert.Equal(new ScalarTypeSnapshot(ModelKind.BigInt, null, null, null, null), newProjection.DeclaredType); + Assert.Null(delayed.Response.ColumnNames); + Assert.Empty(delayed.Response.Columns); + Assert.Null(repeated.Response.ColumnNames); + Assert.Empty(repeated.Response.Columns); + + // Reusing immutable schema data must not cache per-response validation. + var invalidDuration = Compact(44); + invalidDuration.Elapsed = new Duration { Seconds = 1, Nanos = -1 }; + Exception? expected = Record.Exception(() => GrpcModelMapper.ToSessionModel(invalidDuration, delayed.Metadata)); + Exception? actual = Record.Exception(() => GrpcTransportClient.MapScalarProjectionReply(new(invalidDuration, delayed.Metadata))); + Assert.NotNull(expected); + Assert.NotNull(actual); + Assert.Equal(expected.GetType(), actual.GetType()); + Assert.Equal(expected.Message, actual.Message); + var validAgain = GrpcTransportClient.MapScalarProjectionReply(new(Compact(45), delayed.Metadata)); + Assert.True(validAgain.HasCompactInt64); + Assert.Equal(45L, validAgain.Int64); + Assert.Equal(new ScalarTypeSnapshot(ModelKind.Integer, null, null, null, null), validAgain.DeclaredType); + Assert.Equal(4, peer.SqlWrites); + } + + [Fact(Timeout = 30000)] + public async Task RealRental_UsesTransactionOwnershipAndRollbackCleanup_AndRejectsStaleRental() + { + var ct = TestContext.Current.CancellationToken; + var capture = new ProjectionInterceptor(); + await using var host = Host(capture); + var options = Options(host); + try + { + var rental = await CSharpDbClient.RentAsync(options, 1, ct); + try + { + await rental.ExecuteSqlAsync("CREATE TABLE scalar_private (id INTEGER PRIMARY KEY, value INTEGER)", ct); + var scalar = Assert.IsAssignableFrom(rental); + Assert.True(scalar.SupportsScalarProjection); + var transaction = await rental.BeginTransactionAsync(ct); + await rental.ExecuteInTransactionAsync(transaction.TransactionId, + "INSERT INTO scalar_private VALUES (1, 42)", ct); + var result = await scalar.ExecuteScalarProjectionAsync( + "SELECT value FROM scalar_private WHERE id = 1", transaction.TransactionId, ct); + Assert.True(result.HasCompactInt64); + Assert.Equal(42L, result.Int64); + Assert.Equal(ModelKind.Integer, Assert.IsType(result.DeclaredType).Kind); + int commands = capture.SqlCommandCount; + await Assert.ThrowsAsync(async () => + await scalar.ExecuteScalarProjectionAsync("SELECT 99", "not-owned", ct)); + Assert.Equal(commands, capture.SqlCommandCount); + await rental.DisposeAsync(); // The active transaction must be rolled back before owner reuse. + await Assert.ThrowsAsync(async () => + await scalar.ExecuteScalarProjectionAsync("SELECT 99", null, ct)); + Assert.Equal(commands, capture.SqlCommandCount); + } + finally { await rental.DisposeAsync(); } + + await using var next = await CSharpDbClient.RentAsync(options, 1, ct); + var sdk = await next.ExecuteSqlAsync("SELECT value FROM scalar_private WHERE id = 1", ct); + Assert.Empty(sdk.Rows!); + var projected = await Assert.IsAssignableFrom(next) + .ExecuteScalarProjectionAsync("SELECT CAST(7 AS BIGINT) AS value", null, ct); + Assert.True(projected.HasCompactInt64); + Assert.Equal(7L, projected.Int64); + Assert.Equal(ModelKind.BigInt, Assert.IsType(projected.DeclaredType).Kind); + Assert.Single(capture.Requests, r => r.RollbackTransaction is not null); + Assert.Single(capture.Requests, r => r.ExecuteInTransaction?.Sql.StartsWith("SELECT value", StringComparison.Ordinal) == true); + } + finally { await CSharpDbClient.ClearPoolAsync(options.Endpoint!); } + } + + [Fact(Timeout = 30000)] + public async Task RealRental_QueuedCancellationSendsNothing_AndDisposeDrainsActiveProjection() + { + var ct = TestContext.Current.CancellationToken; + var capture = new ProjectionInterceptor(blockFirstResult: true); + await using var host = Host(capture); + var options = Options(host); + try + { + var rental = await CSharpDbClient.RentAsync(options, 1, ct); + try + { + var scalar = Assert.IsAssignableFrom(rental); + Task active = scalar.ExecuteScalarProjectionAsync( + "SELECT CAST(41 AS INTEGER) AS value", null, ct).AsTask(); + await capture.FirstResultReached.Task.WaitAsync(TimeSpan.FromSeconds(15), ct); + using var queuedCancellation = CancellationTokenSource.CreateLinkedTokenSource(ct); + Task queued = scalar.ExecuteScalarProjectionAsync( + "SELECT CAST(99 AS INTEGER) AS value", null, queuedCancellation.Token).AsTask(); + Assert.False(queued.IsCompleted); + queuedCancellation.Cancel(); + var canceled = await Assert.ThrowsAnyAsync(async () => await queued); + Assert.Equal(queuedCancellation.Token, canceled.CancellationToken); + Assert.False(active.IsCompleted); + Assert.Equal(1, capture.SqlCommandCount); + + Task disposing = rental.DisposeAsync().AsTask(); + Assert.False(disposing.IsCompleted); + capture.ReleaseFirstResult.TrySetResult(); + Assert.Equal(41L, (await active.WaitAsync(TimeSpan.FromSeconds(15), ct)).Int64); + await disposing; + await Assert.ThrowsAsync(async () => + await scalar.ExecuteScalarProjectionAsync("SELECT 100", null, ct)); + Assert.Equal(1, capture.SqlCommandCount); + Assert.DoesNotContain(capture.Requests, r => r.ExecuteSql?.Sql.Contains("99", StringComparison.Ordinal) == true); + } + finally + { + capture.ReleaseFirstResult.TrySetResult(); + await rental.DisposeAsync(); + } + } + finally { await CSharpDbClient.ClearPoolAsync(options.Endpoint!); } + } + + [Fact(Timeout = 30000)] + public async Task RealRental_InvalidCompactResponse_FaultsBeforeProjection_WithoutReplay() + { + var ct = TestContext.Current.CancellationToken; + var capture = new ProjectionInterceptor(corruptFirstCompact: true); + await using var host = Host(capture); + var options = Options(host); + try + { + await using var rental = await CSharpDbClient.RentAsync(options, 1, ct); + var scalar = Assert.IsAssignableFrom(rental); + var error = await Record.ExceptionAsync(async () => + await scalar.ExecuteScalarProjectionAsync("SELECT CAST(42 AS INTEGER) AS value", null, ct)); + Assert.NotNull(error); + Assert.IsType(error); + Assert.Contains("compact integer", error.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(1, capture.SqlCommandCount); + await Assert.ThrowsAsync(async () => + await scalar.ExecuteScalarProjectionAsync("SELECT CAST(99 AS INTEGER) AS value", null, ct)); + Assert.Equal(1, capture.SqlCommandCount); + Assert.Single(capture.Requests, r => r.Hello is not null); + } + finally { await CSharpDbClient.ClearPoolAsync(options.Endpoint!); } + } + + [Fact(Timeout = 30000)] + public async Task PooledMapper_RepeatedMixedCompletions_DoNotRetainPriorResultErrorOrToken() + { + var retained = new List(); + for (int index = 0; index < 72; index++) + { + using var work = NewPooledWork(index, index % 6, TestContext.Current.CancellationToken); + bool completedAtCall = index / 6 % 2 == 0; + if (completedAtCall) CompletePooledWork(work); + ValueTask operation = InvokePooledMapper(work); + Assert.Equal(completedAtCall, operation.IsCompleted); + if (!completedAtCall) CompletePooledWork(work); // Exercise suspended and synchronous completion for every mode. + RetainedProjection? result = await CheckPooledWorkAsync(operation, work); + if (result is not null) retained.Add(result); + if (index % 6 == 5) AssertRetainedProjections(retained); + } + Assert.Equal(24, retained.Count); + AssertRetainedProjections(retained); + } + + [Fact(Timeout = 30000)] + public async Task PooledMapper_ConcurrentMixedCompletions_KeepIndependentPayloadsFailuresAndTokens() + { + var retained = new List(); + for (int batch = 0; batch < 8; batch++) + { + PooledWork[] work = Enumerable.Range(0, 12) + .Select(lane => NewPooledWork(1000 + batch * 12 + lane, lane % 6, TestContext.Current.CancellationToken)) + .ToArray(); + try + { + Task[] observations = work.Select(item => + { + ValueTask operation = InvokePooledMapper(item); + Assert.False(operation.IsCompleted); + return CheckPooledWorkAsync(operation, item); // Consume each ValueTask exactly once. + }).ToArray(); + Assert.All(observations, observation => Assert.False(observation.IsCompleted)); + // Every operation is pending before completion; alternate completion order. + foreach (int lane in batch % 2 == 0 ? Enumerable.Range(0, 12).Reverse() : Enumerable.Range(0, 12)) + CompletePooledWork(work[lane]); + RetainedProjection?[] completed = await Task.WhenAll(observations) + .WaitAsync(TimeSpan.FromSeconds(15), TestContext.Current.CancellationToken); + retained.AddRange(completed.OfType()); + AssertRetainedProjections(retained); + } + finally + { + foreach (PooledWork item in work) + { + item.Completion.TrySetCanceled(CancellationToken.None); + item.Dispose(); + } + } + } + Assert.Equal(32, retained.Count); + AssertRetainedProjections(retained); + } + + private static ValueTask InvokePooledMapper(PooledWork work) + { + // Target the actual pooled mapper without adding a production API for tests. + MethodInfo method = typeof(GrpcTransportClient).GetMethod("MapScalarProjectionAsync", + BindingFlags.NonPublic | BindingFlags.Static, binder: null, + types: [typeof(Task), typeof(CancellationToken)], modifiers: null) + ?? throw new InvalidOperationException("The private scalar response mapper was not found."); + return Assert.IsType>( + method.Invoke(null, [work.Completion.Task, work.Cancellation.Token])); + } + + private static PooledWork NewPooledWork(int seed, int mode, CancellationToken token) + { + ModelKind kind = seed / 6 % 2 == 0 ? ModelKind.Integer : ModelKind.BigInt; + string name = $"pooled_value_{seed}"; + long value = 100000L + seed; + var definition = Definition(name); + definition.Columns[0].DeclaredType.Kind = kind == ModelKind.Integer + ? SqlTypeKindEnum.SqlTypeKindInteger : SqlTypeKindEnum.SqlTypeKindBigInt; + var metadata = new GrpcSessionResultMetadata(definition); + var response = Compact(value); + if (mode == 1) + { + response.ClearScalarInt64(); + response.Rows = Rows(-value); + } + if (mode == 5) response.Elapsed = new Duration { Seconds = 1, Nanos = -1 }; + Exception? fault = mode switch + { + 2 => new RpcException(new Status(StatusCode.Unavailable, $"pooled_rpc_{seed}")), + 3 => new RpcException(new Status(StatusCode.Cancelled, $"pooled_cancel_{seed}")), + 4 => new InvalidOperationException($"pooled_untranslated_{seed}"), + _ => null, + }; + return new PooledWork + { + Seed = seed, Mode = mode, Kind = kind, Name = name, Value = value, + Cancellation = CancellationTokenSource.CreateLinkedTokenSource(token), + Reply = new(response, metadata), Fault = fault, + ExpectedMappingFailure = mode == 5 + ? Record.Exception(() => GrpcModelMapper.ToSessionModel(response, metadata)) : null, + }; + } + + private static void CompletePooledWork(PooledWork work) + { + if (work.Mode == 3) work.Cancellation.Cancel(); + if (work.Fault is { } fault) work.Completion.SetException(fault); + else work.Completion.SetResult(work.Reply); + } + + private static async Task CheckPooledWorkAsync( + ValueTask operation, PooledWork work) + { + ScalarProjectionResult result = default; + Exception? error = null; + try { result = await operation; } + catch (Exception actual) { error = actual; } + switch (work.Mode) + { + case 0: + case 1: + Assert.Null(error); + var retained = new RetainedProjection(result, work.Mode == 0, work.Value, work.Kind, work.Name); + AssertRetainedProjections([retained]); + return retained; + case 2: + Assert.Contains($"pooled_rpc_{work.Seed}", Assert.IsAssignableFrom(error).Message); + break; + case 3: + Assert.Equal(work.Cancellation.Token, + Assert.IsAssignableFrom(error).CancellationToken); + break; + case 4: + Assert.Same(work.Fault, error); + break; + default: + Assert.NotNull(work.ExpectedMappingFailure); + Assert.NotNull(error); + Assert.Equal(work.ExpectedMappingFailure.GetType(), error.GetType()); + Assert.Equal(work.ExpectedMappingFailure.Message, error.Message); + break; + } + return null; + } + + private static void AssertRetainedProjections(IEnumerable retained) + { + foreach (RetainedProjection item in retained) + { + Assert.Equal(item.Compact, item.Result.HasCompactInt64); + if (item.Compact) + { + Assert.Equal(item.Value, item.Result.Int64); + Assert.Null(item.Result.Fallback); + Assert.Equal(item.Kind, Assert.IsType(item.Result.DeclaredType).Kind); + } + else + { + Assert.Null(item.Result.DeclaredType); + SqlExecutionResult fallback = Assert.IsType(item.Result.Fallback); + Assert.Equal(item.Name, Assert.Single(fallback.ColumnNames!)); + Assert.Equal(-item.Value, Assert.IsType(Assert.Single(Assert.Single(fallback.Rows!)))); + Assert.Equal(item.Kind, Assert.Single(fallback.Columns!).DeclaredType!.Kind); + } + } + } + + private sealed record RetainedProjection(ScalarProjectionResult Result, bool Compact, long Value, ModelKind Kind, string Name); + private sealed class PooledWork : IDisposable + { + public required int Seed { get; init; } + public required int Mode { get; init; } + public required ModelKind Kind { get; init; } + public required string Name { get; init; } + public required long Value { get; init; } + public required CancellationTokenSource Cancellation { get; init; } + public required GrpcCommandSession.SqlReply Reply { get; init; } + public Exception? Fault { get; init; } + public Exception? ExpectedMappingFailure { get; init; } + public TaskCompletionSource Completion { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public void Dispose() => Cancellation.Dispose(); + } + + private static void AssertResultEqual(SqlExecutionResult expected, SqlExecutionResult actual) + { + Assert.Equal(expected.IsQuery, actual.IsQuery); + Assert.Equal(expected.RowsAffected, actual.RowsAffected); + Assert.Equal(expected.Error, actual.Error); + Assert.Equal(expected.Elapsed, actual.Elapsed); + Assert.Equal(expected.ColumnNames, actual.ColumnNames); + Assert.Equal(expected.ColumnTypes, actual.ColumnTypes); + Assert.Equal(expected.ColumnNullability, actual.ColumnNullability); + if (expected.Columns is null) Assert.Null(actual.Columns); + else + { + Assert.NotNull(actual.Columns); + Assert.Equal(expected.Columns.Length, actual.Columns.Length); + for (int i = 0; i < expected.Columns.Length; i++) + { + Assert.Equal(expected.Columns[i].Name, actual.Columns[i].Name); + Assert.Equal(expected.Columns[i].Type, actual.Columns[i].Type); + Assert.Equal(expected.Columns[i].DeclaredType?.Kind, actual.Columns[i].DeclaredType?.Kind); + } + } + if (expected.Rows is null) Assert.Null(actual.Rows); + else + { + Assert.NotNull(actual.Rows); + Assert.Equal(expected.Rows.Count, actual.Rows.Count); + for (int i = 0; i < expected.Rows.Count; i++) + Assert.Equal(expected.Rows[i], actual.Rows[i]); + } + } + + private static CSharpDbClientOptions Options(GrpcSocketDaemonFixture host) => new() + { + Transport = CSharpDbTransport.Grpc, GrpcMode = CSharpDbGrpcMode.Native, + GrpcCallMode = CSharpDbGrpcCallMode.Streaming, Endpoint = host.NativeEndpoint.AbsoluteUri, + }; + + private static GrpcSocketDaemonFixture Host(ProjectionInterceptor capture) => new(configureServices: services => + { + services.AddSingleton(capture); + services.AddGrpc(options => options.Interceptors.Add()); + }); + + private static VariantArrayList Rows(object? value) + => GrpcModelMapper.ToVariantArrayList([new object?[] { value }]); + private static SqlExecutionResultMessage Compact(long value) + => new() { IsQuery = true, ScalarInt64 = value, Elapsed = new Duration() }; + private static CommandSessionRequest Query() => new() { ExecuteSql = new() { Sql = "SELECT scripted_private_scalar" } }; + private static CommandSessionResponse Full(ulong id, SqlExecutionResultMessage definition) + { + definition.Rows = null; definition.ScalarInt64 = 1; + return new() { ResultSchemaId = id, Result = definition }; + } + private static CommandSessionResponse Reference(ulong id, long value) + => new() { ResultSchemaId = id, ResultSchemaReference = true, Result = Compact(value) }; + + private static SqlExecutionResultMessage Definition(string name) + { + var result = new SqlExecutionResultMessage + { + IsQuery = true, Elapsed = new Duration(), + ColumnNames = GrpcModelMapper.ToStringList([name]), + ColumnTypes = GrpcModelMapper.ToStringList(["INTEGER"]), + ColumnNullability = GrpcModelMapper.ToBoolList([true]), + }; + result.Columns.Add(new ColumnDefinitionMessage + { + SchemaId = "12345678-1234-5678-9012-123456789012", Name = name, + Type = DbTypeEnum.DbTypeInteger, Nullable = true, + DeclaredType = new() { Kind = SqlTypeKindEnum.SqlTypeKindInteger }, + }); + return result; + } + + public sealed class ProjectionInterceptor(bool blockFirstResult = false, bool corruptFirstCompact = false) : Interceptor + { + private readonly bool _blockFirstResult = blockFirstResult; + private readonly bool _corruptFirstCompact = corruptFirstCompact; + private int _results; + public ConcurrentQueue Requests { get; } = new(); + public TaskCompletionSource FirstResultReached { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource ReleaseFirstResult { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public int SqlCommandCount => Requests.Count(r => r.ExecuteSql is not null || r.ExecuteInTransaction is not null); + public override Task DuplexStreamingServerHandler(IAsyncStreamReader reader, + IServerStreamWriter writer, ServerCallContext context, + DuplexStreamingServerMethod continuation) + => continuation(new CapturingReader(reader, this), + new ControlledWriter(writer, this, context.CancellationToken), context); + + private sealed class CapturingReader(IAsyncStreamReader inner, ProjectionInterceptor owner) : IAsyncStreamReader + { + public T Current => inner.Current; + public async Task MoveNext(CancellationToken ct) + { + if (!await inner.MoveNext(ct)) return false; + if (Current is CommandSessionRequest request) owner.Requests.Enqueue(request.Clone()); + return true; + } + } + private sealed class ControlledWriter(IServerStreamWriter inner, ProjectionInterceptor owner, + CancellationToken ct) : IServerStreamWriter + { + public WriteOptions? WriteOptions { get => inner.WriteOptions; set => inner.WriteOptions = value; } + public async Task WriteAsync(T message) + { + if (message is CommandSessionResponse { Result: not null } response && + Interlocked.Increment(ref owner._results) == 1) + { + owner.FirstResultReached.TrySetResult(); + if (owner._blockFirstResult) await owner.ReleaseFirstResult.Task.WaitAsync(ct); + if (owner._corruptFirstCompact) + { + Assert.True(response.Result.HasScalarInt64); + response = response.Clone(); + response.Result.Rows = new VariantArrayList(); + message = (T)(object)response; + } + } + await inner.WriteAsync(message); + } + } + } + + private sealed class ScriptedPeer(params CommandSessionResponse[] responses) + { + private readonly Queue _script = new(responses); + private readonly ConcurrentQueue _received = new(); + public int SqlWrites; + public bool Completed; + public void Write(CommandSessionRequest request) + { + if (request.Hello is not null) + { + _received.Enqueue(new() { Ready = new() { ProtocolVersion = 1, MaxInFlight = 1, + Capabilities = { "sql", "transactions", "result-schema-ref-v1", "single-int64-result-v1" } } }); + return; + } + if (request.ExecuteSql is not null) SqlWrites++; + var response = _script.Dequeue(); + response.RequestId = request.RequestId; + _received.Enqueue(response); + } + public bool Read(out CommandSessionResponse response) => _received.TryDequeue(out response!); + } + + private sealed class ScriptedInvoker(ScriptedPeer peer) : CallInvoker + { + public override AsyncDuplexStreamingCall AsyncDuplexStreamingCall( + Method method, string? host, CallOptions options) + => new((IClientStreamWriter)(object)new Writer(peer), + (IAsyncStreamReader)(object)new Reader(peer), Task.FromResult(new Metadata()), + () => Status.DefaultSuccess, () => new Metadata(), () => { }); + public override TResponse BlockingUnaryCall(Method method, string? host, CallOptions options, TRequest request) => throw new NotSupportedException(); + public override AsyncUnaryCall AsyncUnaryCall(Method method, string? host, CallOptions options, TRequest request) => throw new NotSupportedException(); + public override AsyncServerStreamingCall AsyncServerStreamingCall(Method method, string? host, CallOptions options, TRequest request) => throw new NotSupportedException(); + public override AsyncClientStreamingCall AsyncClientStreamingCall(Method method, string? host, CallOptions options) => throw new NotSupportedException(); + private sealed class Writer(ScriptedPeer state) : IClientStreamWriter + { + public WriteOptions? WriteOptions { get; set; } + public Task WriteAsync(CommandSessionRequest message) { state.Write(message); return Task.CompletedTask; } + public Task WriteAsync(CommandSessionRequest message, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + return WriteAsync(message); + } + public Task CompleteAsync() { state.Completed = true; return Task.CompletedTask; } + } + private sealed class Reader(ScriptedPeer state) : IAsyncStreamReader + { + public CommandSessionResponse Current { get; private set; } = null!; + public Task MoveNext(CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (state.Read(out var next)) { Current = next; return Task.FromResult(true); } + if (!state.Completed) throw new InvalidOperationException("No response queued by the scripted command."); + return Task.FromResult(false); + } + } + } +} diff --git a/tests/CSharpDB.Daemon.Tests/GrpcResultSchemaReuseTests.cs b/tests/CSharpDB.Daemon.Tests/GrpcResultSchemaReuseTests.cs new file mode 100644 index 00000000..1d441f98 --- /dev/null +++ b/tests/CSharpDB.Daemon.Tests/GrpcResultSchemaReuseTests.cs @@ -0,0 +1,429 @@ +using System.Collections.Concurrent; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Text.Json; +using System.Text.Json.Nodes; +using CSharpDB.Client; +using CSharpDB.Client.Grpc; +using CSharpDB.Client.Models; +using CSharpDB.Testing; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; +using Grpc.Core.Interceptors; +using Grpc.Net.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using ModelColumn = CSharpDB.Client.Models.ColumnDefinition; +using ModelType = CSharpDB.Client.Models.DbType; +using ModelSqlKind = CSharpDB.Client.Models.SqlTypeKind; +using ModelSqlType = CSharpDB.Client.Models.SqlTypeDescriptor; + +namespace CSharpDB.Daemon.Tests; + +public sealed class GrpcResultSchemaReuseTests +{ + private const string Capability = "result-schema-ref-v1"; + + [Theory(Timeout = 30000)] + [InlineData(false)] + [InlineData(true)] + public async Task RawPeer_NegotiatesOnlyWhenOffered_AndReferenceSavesSerializedBytes(bool offer) + { + var ct = TestContext.Current.CancellationToken; + await using var host = new GrpcSocketDaemonFixture(); + await using var session = await RawSession.OpenAsync(host, offer, ct); + Assert.Equal(offer, session.Ready.Capabilities.Contains(Capability)); + const string sql = "SELECT CAST(42 AS INTEGER) AS result_schema_value"; + var full = await session.SqlAsync(sql, ct); + var repeat = await session.SqlAsync(sql, ct); + Assert.NotNull(full.Result.ColumnNames); + Assert.NotEmpty(full.Result.Columns); + if (offer) + { + AssertFull(full, 1); + AssertReference(repeat, 1); + Assert.Equal(full.Result.Rows, repeat.Result.Rows); + // Normalize elapsed timing and request ID to isolate schema bytes. + var normalizedFull = full.Clone(); var normalizedReference = repeat.Clone(); + normalizedFull.RequestId = normalizedReference.RequestId = 1; + normalizedFull.Result.Elapsed = normalizedReference.Result.Elapsed = new Duration(); + int fullBytes = normalizedFull.CalculateSize(), referenceBytes = normalizedReference.CalculateSize(); + Assert.True(referenceBytes < fullBytes, $"Full {fullBytes} bytes; reference {referenceBytes} bytes."); + TestContext.Current.TestOutputHelper?.WriteLine($"Schema wire bytes: actual full={full.CalculateSize()}, ref={repeat.CalculateSize()}; normalized full={fullBytes}, ref={referenceBytes}, saved={fullBytes - referenceBytes}."); + } + else + { + Assert.Equal(0UL, full.ResultSchemaId); + Assert.Equal(0UL, repeat.ResultSchemaId); + Assert.False(full.ResultSchemaReference); + Assert.False(repeat.ResultSchemaReference); + Assert.Equal(full.Result.ColumnNames, repeat.Result.ColumnNames); + Assert.Equal(full.Result.Columns, repeat.Result.Columns); + } + } + + [Fact(Timeout = 30000)] + public async Task RawSession_InvalidatesOnDmlErrorsInfoTransactionsAndDdl_WithoutReusingIds() + { + var ct = TestContext.Current.CancellationToken; + await using var host = new GrpcSocketDaemonFixture(); + await using var session = await RawSession.OpenAsync(host, true, ct); + AssertNoSchema(await session.SqlAsync("CREATE TABLE schema_invalidation (id INTEGER PRIMARY KEY, value INTEGER); INSERT INTO schema_invalidation VALUES (1, 42)", ct)); + const string sql = "SELECT value FROM schema_invalidation WHERE id = 1"; + AssertFull(await session.SqlAsync(sql, ct), 1); + AssertReference(await session.SqlAsync(sql, ct), 1); + AssertNoSchema(await session.SqlAsync("UPDATE schema_invalidation SET value = 43 WHERE id = 1", ct)); + AssertFull(await session.SqlAsync(sql, ct), 2); + AssertReference(await session.SqlAsync(sql, ct), 2); + AssertNoSchema(await session.SendAsync(new() { GetInfo = new Empty() }, ct)); + AssertFull(await session.SqlAsync(sql, ct), 3); + var error = await session.SqlAsync("SELECT missing FROM schema_invalidation", ct); + Assert.True(error.Error is not null || !string.IsNullOrEmpty(error.Result?.Error)); + AssertNoSchema(error); + AssertFull(await session.SqlAsync(sql, ct), 4); + var begin = await session.SendAsync(new() { BeginTransaction = new Empty() }, ct); + AssertNoSchema(begin); + string id = begin.Transaction.TransactionId; + AssertFull(await session.TransactionSqlAsync(id, sql, ct), 5); + AssertReference(await session.TransactionSqlAsync(id, sql, ct), 5); + const string projection = "SELECT CAST(value AS BIGINT) AS value FROM schema_invalidation WHERE id = 1"; + AssertFull(await session.TransactionSqlAsync(id, projection, ct), 6); + AssertReference(await session.TransactionSqlAsync(id, projection, ct), 6); + AssertNoSchema(await session.SendAsync(new() { RollbackTransaction = new() { TransactionId = id } }, ct)); + AssertFull(await session.SqlAsync(sql, ct), 7); + AssertNoSchema(await session.SqlAsync("DROP TABLE schema_invalidation; CREATE TABLE schema_invalidation (id INTEGER PRIMARY KEY, value BIGINT); INSERT INTO schema_invalidation VALUES (1, 43)", ct)); + var changed = await session.SqlAsync(sql, ct); + AssertFull(changed, 8); + Assert.Equal(SqlTypeKindEnum.SqlTypeKindBigInt, changed.Result.Columns[0].DeclaredType.Kind); + begin = await session.SendAsync(new() { BeginTransaction = new Empty() }, ct); + AssertNoSchema(begin); + id = begin.Transaction.TransactionId; + AssertFull(await session.TransactionSqlAsync(id, sql, ct), 9); + AssertNoSchema(await session.SendAsync(new() { CommitTransaction = new() { TransactionId = id } }, ct)); + AssertFull(await session.SqlAsync(sql, ct), 10); + } + + [Theory(Timeout = 30000)] + [InlineData("SchemaId")] + [InlineData("Name")] + [InlineData("Type")] + [InlineData("Nullable")] + [InlineData("IsPrimaryKey")] + [InlineData("IsIdentity")] + [InlineData("IsRowVersion")] + [InlineData("Collation")] + [InlineData("DefaultSql")] + [InlineData("DeclaredKind")] + [InlineData("DeclaredLength")] + [InlineData("DeclaredPrecision")] + [InlineData("DeclaredScale")] + [InlineData("DeclaredFractionalSeconds")] + [InlineData("DeclaredPresence")] + [InlineData("NamesNullEmpty")] + [InlineData("TypesNullEmpty")] + [InlineData("NullabilityNullEmpty")] + public async Task ActualMetadataChange_RequiresNewFullSchema_EvenWhenSqlAndRowsAreIdentical(string change) + { + var ct = TestContext.Current.CancellationToken; + var baseline = Metadata(change, changed: false); + var changed = Metadata(change, changed: true); + var control = new ResponseControl { Result = baseline }; + await using var host = new GrpcSocketDaemonFixture(configureServices: services => ReplaceBackend(services, control)); + await using var session = await RawSession.OpenAsync(host, true, ct); + const string sql = "SELECT controlled_metadata"; + var first = await session.SqlAsync(sql, ct); + AssertFull(first, 1); + AssertReference(await session.SqlAsync(sql, ct), 1); + Volatile.Write(ref control.Result, changed); + var next = await session.SqlAsync(sql, ct); + AssertFull(next, 2); + AssertReference(await session.SqlAsync(sql, ct), 2); + Assert.Equal(first.Result.Rows, next.Result.Rows); + Assert.Equal(4, control.SqlCalls); + } + + [Fact(Timeout = 30000)] + public async Task PooledSdkResults_AreFreshAfterCallerMutation_AndAcrossAlternatingProjections() + { + var ct = TestContext.Current.CancellationToken; + var capture = new SessionInterceptor(); + await using var host = Host(capture); + var options = Options(host); + const string sql = "SELECT CAST(42 AS INTEGER) AS fresh_value"; + try + { + SqlExecutionResult first; + string[] expectedNames, expectedTypes; bool[] expectedNullability; + await using (var rental = await CSharpDbClient.RentAsync(options, 1, ct)) + { + first = await rental.ExecuteSqlAsync(sql, ct); + expectedNames = first.ColumnNames!.ToArray(); expectedTypes = first.ColumnTypes!.ToArray(); + expectedNullability = first.ColumnNullability!.ToArray(); + first.ColumnNames![0] = "poison"; first.ColumnTypes![0] = "BLOB"; + first.ColumnNullability![0] = !first.ColumnNullability[0]; + first.Columns![0] = new ModelColumn { Name = "poison", Type = ModelType.Blob }; + first.Rows![0][0] = "poison"; + } + await using var next = await CSharpDbClient.RentAsync(options, 1, ct); + var second = await next.ExecuteSqlAsync(sql, ct); + Assert.Equal(expectedNames, second.ColumnNames); + Assert.Equal(expectedTypes, second.ColumnTypes); + Assert.Equal(expectedNullability, second.ColumnNullability); + Assert.Equal("fresh_value", second.Columns![0].Name); + Assert.Equal(ModelSqlKind.Integer, second.Columns[0].DeclaredType!.Kind); + Assert.Equal(42L, second.Rows![0][0]); + Assert.NotSame(first.ColumnNames, second.ColumnNames); + Assert.NotSame(first.ColumnTypes, second.ColumnTypes); + Assert.NotSame(first.ColumnNullability, second.ColumnNullability); + Assert.NotSame(first.Columns, second.Columns); + Assert.NotSame(first.Rows, second.Rows); + var firstReplies = capture.Responses.Where(r => r.Result is not null).ToArray(); + Assert.Equal(2, firstReplies.Length); + AssertFull(firstReplies[0], 1); AssertReference(firstReplies[1], 1); + var work = Enumerable.Range(0, 24).Select(async n => + { + string type = n % 2 == 0 ? "INTEGER" : "BIGINT"; + string alias = n % 2 == 0 ? "small_value" : "large_value"; + var result = await next.ExecuteSqlAsync($"SELECT CAST({n} AS {type}) AS {alias}", ct); + Assert.Equal(alias, Assert.Single(result.ColumnNames!)); + Assert.Equal(n % 2 == 0 ? ModelSqlKind.Integer : ModelSqlKind.BigInt, Assert.Single(result.Columns!).DeclaredType!.Kind); + Assert.Equal((long)n, result.Rows![0][0]); + }); + await Task.WhenAll(work); + Assert.Equal(1, HttpCount(host, "CommandSession")); + } + finally { await CSharpDbClient.ClearPoolAsync(host.NativeEndpoint.AbsoluteUri); } + } + + [Fact(Timeout = 30000)] + public async Task NewClient_WithNonNegotiatingPeer_UsesFullMetadataOnEveryResult() + { + var ct = TestContext.Current.CancellationToken; + var capture = new SessionInterceptor("no-echo"); + await using var host = Host(capture); + await using var client = CSharpDbClient.Create(Options(host)); + for (int i = 0; i < 3; i++) + { + var result = await client.ExecuteSqlAsync("SELECT CAST(42 AS INTEGER) AS legacy_value", ct); + Assert.Equal("legacy_value", Assert.Single(result.ColumnNames!)); + Assert.Equal(ModelSqlKind.Integer, Assert.Single(result.Columns!).DeclaredType!.Kind); + Assert.Equal(42L, result.Rows![0][0]); + } + Assert.DoesNotContain(Capability, capture.Responses.Single(r => r.Ready is not null).Ready.Capabilities); + Assert.All(capture.Responses.Where(r => r.Result is not null), response => + { + AssertNoSchema(response); + Assert.NotNull(response.Result.ColumnNames); + Assert.NotEmpty(response.Result.Columns); + }); + Assert.Equal(3, capture.SqlCommands); + } + + [Theory(Timeout = 30000)] + [InlineData("unknown-id")] + [InlineData("mixed-reference")] + [InlineData("unsolicited-reference")] + [InlineData("full-gap")] + public async Task MalformedReference_ClosesSession_WithoutReplayingTheSql(string corruption) + { + var ct = TestContext.Current.CancellationToken; + var capture = new SessionInterceptor(corruption); + await using var host = Host(capture); + await using var client = CSharpDbClient.Create(Options(host)); + bool warm = corruption is "unknown-id" or "mixed-reference"; + if (warm) Assert.Null((await client.ExecuteSqlAsync("SELECT 42 AS value", ct)).Error); + var error = await Assert.ThrowsAsync(() => client.ExecuteSqlAsync("SELECT 42 AS value", ct)); + Assert.Equal(StatusCode.DataLoss, Assert.IsType(error.InnerException).StatusCode); + await Assert.ThrowsAsync(() => client.ExecuteSqlAsync("SELECT 99 AS value", ct)); + Assert.Equal(warm ? 2 : 1, capture.SqlCommands); + Assert.Equal(1, HttpCount(host, "CommandSession")); + } + + private static void AssertFull(CommandSessionResponse response, ulong id) + { + Assert.NotNull(response.Result); Assert.True(response.Result.IsQuery); Assert.Null(response.Result.Error); + Assert.Equal(id, response.ResultSchemaId); Assert.False(response.ResultSchemaReference); + } + private static void AssertReference(CommandSessionResponse response, ulong id) + { + Assert.Equal(id, response.ResultSchemaId); Assert.True(response.ResultSchemaReference); + Assert.Null(response.Result.ColumnNames); Assert.Null(response.Result.ColumnTypes); + Assert.Null(response.Result.ColumnNullability); Assert.Empty(response.Result.Columns); + } + private static void AssertNoSchema(CommandSessionResponse response) + { Assert.Equal(0UL, response.ResultSchemaId); Assert.False(response.ResultSchemaReference); } + private static CSharpDbClientOptions Options(GrpcSocketDaemonFixture host) => new() + { + Transport = CSharpDbTransport.Grpc, GrpcMode = CSharpDbGrpcMode.Native, + GrpcCallMode = CSharpDbGrpcCallMode.Streaming, Endpoint = host.NativeEndpoint.AbsoluteUri, + }; + private static long HttpCount(GrpcSocketDaemonFixture host, string method) + => host.Snapshot().Where(x => x.Key.Method.EndsWith("/" + method, StringComparison.Ordinal)).Sum(x => x.Value); + private static GrpcSocketDaemonFixture Host(SessionInterceptor capture) => new(configureServices: services => + { + services.AddSingleton(capture); + services.AddGrpc(options => options.Interceptors.Add()); + }); + + private static SqlExecutionResult Metadata(string change, bool changed) + { + var declared = new ModelSqlType { Kind = ModelSqlKind.VarChar, Length = 20 }; + if (change is "DeclaredPrecision" or "DeclaredScale") declared = new() { Kind = ModelSqlKind.Decimal, Precision = 10, Scale = 2 }; + if (change == "DeclaredFractionalSeconds") declared = new() { Kind = ModelSqlKind.Timestamp, FractionalSecondsPrecision = 3 }; + var column = new ModelColumn + { + SchemaId = Guid.Parse("10000000-0000-0000-0000-000000000001"), Name = "value", Type = ModelType.Text, + Nullable = false, DeclaredType = change == "DeclaredPresence" && !changed ? null : declared, + }; + if (changed && change is not ("NamesNullEmpty" or "TypesNullEmpty" or "NullabilityNullEmpty" or "DeclaredPresence")) + { + JsonObject node = JsonSerializer.SerializeToNode(column)!.AsObject(); + switch (change) + { + case "SchemaId": node["SchemaId"] = "20000000-0000-0000-0000-000000000002"; break; + case "Name": node["Name"] = "other"; break; + case "Type": node["Type"] = (int)ModelType.Blob; break; + case "Nullable": node["Nullable"] = true; break; + case "IsPrimaryKey": case "IsIdentity": case "IsRowVersion": node[change] = true; break; + case "Collation": node["Collation"] = "NOCASE"; break; + case "DefaultSql": node["DefaultSql"] = "'default'"; break; + case "DeclaredKind": node["DeclaredType"]!["Kind"] = (int)ModelSqlKind.Text; break; + case "DeclaredLength": node["DeclaredType"]!["Length"] = 40; break; + case "DeclaredPrecision": node["DeclaredType"]!["Precision"] = 12; break; + case "DeclaredScale": node["DeclaredType"]!["Scale"] = 3; break; + case "DeclaredFractionalSeconds": node["DeclaredType"]!["FractionalSecondsPrecision"] = 4; break; + default: throw new ArgumentOutOfRangeException(nameof(change)); + } + column = node.Deserialize()!; + } + return new() + { + IsQuery = true, Rows = [new object?[] { "row" }], Columns = [column], + ColumnNames = change == "NamesNullEmpty" ? changed ? [] : null : ["value"], + ColumnTypes = change == "TypesNullEmpty" ? changed ? [] : null : ["VARCHAR(20)"], + ColumnNullability = change == "NullabilityNullEmpty" ? changed ? [] : null : [false], + }; + } + private static void ReplaceBackend(IServiceCollection services, ResponseControl control) + { + var original = services.Last(s => s.ServiceType == typeof(ICSharpDbClient)); + services.Replace(ServiceDescriptor.Singleton(provider => + { + var proxy = DispatchProxy.Create(); + ((ControlledBackend)(object)proxy).Inner = (ICSharpDbClient)original.ImplementationFactory!(provider); + ((ControlledBackend)(object)proxy).Control = control; + return proxy; + })); + } + public sealed class ResponseControl { public SqlExecutionResult Result = null!; public int SqlCalls; } + public class ControlledBackend : DispatchProxy + { + public ICSharpDbClient Inner = null!; + public ResponseControl Control = null!; + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + if (targetMethod!.Name == nameof(ICSharpDbClient.ExecuteSqlAsync)) + { Interlocked.Increment(ref Control.SqlCalls); return Task.FromResult(Volatile.Read(ref Control.Result)); } + try { return targetMethod.Invoke(Inner, args); } + catch (TargetInvocationException error) when (error.InnerException is not null) + { ExceptionDispatchInfo.Capture(error.InnerException).Throw(); throw; } + } + } + private sealed class RawSession(GrpcChannel channel, AsyncDuplexStreamingCall call, + CommandSessionReady ready) : IAsyncDisposable + { + private ulong _id; + public CommandSessionReady Ready { get; } = ready; + public static async Task OpenAsync(GrpcSocketDaemonFixture host, bool offer, CancellationToken ct) + { + var channel = GrpcChannel.ForAddress(host.NativeEndpoint); + var call = new CSharpDbRpc.CSharpDbRpcClient(channel).CommandSession(cancellationToken: ct); + var hello = new CommandSessionHello { ProtocolVersion = 1 }; + if (offer) hello.Capabilities.Add(Capability); + try + { + await call.RequestStream.WriteAsync(new CommandSessionRequest { Hello = hello }, ct); + Assert.True(await call.ResponseStream.MoveNext(ct)); + return new(channel, call, call.ResponseStream.Current.Ready); + } + catch { call.Dispose(); channel.Dispose(); throw; } + } + public Task SqlAsync(string sql, CancellationToken ct) + => SendAsync(new() { ExecuteSql = new() { Sql = sql } }, ct); + public Task TransactionSqlAsync(string id, string sql, CancellationToken ct) + => SendAsync(new() { ExecuteInTransaction = new() { TransactionId = id, Sql = sql } }, ct); + public async Task SendAsync(CommandSessionRequest request, CancellationToken ct) + { + request.RequestId = ++_id; + await call.RequestStream.WriteAsync(request, ct); + Assert.True(await call.ResponseStream.MoveNext(ct)); + Assert.Equal(_id, call.ResponseStream.Current.RequestId); + return call.ResponseStream.Current; + } + public async ValueTask DisposeAsync() + { + try + { + await call.RequestStream.CompleteAsync().WaitAsync(TimeSpan.FromSeconds(5)); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + Assert.False(await call.ResponseStream.MoveNext(timeout.Token)); + } + finally { call.Dispose(); channel.Dispose(); } + } + } + public sealed class SessionInterceptor(string? mode = null) : Interceptor + { + private readonly string? _mode = mode; + public ConcurrentQueue Responses { get; } = new(); + public int SqlCommands; + private int _results; + private CommandSessionResponse? _first; + public override Task DuplexStreamingServerHandler(IAsyncStreamReader reader, + IServerStreamWriter writer, ServerCallContext context, DuplexStreamingServerMethod continuation) + => continuation(new Reader(reader, this), new Writer(writer, this), context); + private sealed class Reader(IAsyncStreamReader inner, SessionInterceptor owner) : IAsyncStreamReader + { + public T Current { get; private set; } = default!; + public async Task MoveNext(CancellationToken ct) + { + if (!await inner.MoveNext(ct)) return false; + Current = inner.Current; + if (Current is CommandSessionRequest request) + { + if (request.ExecuteSql is not null) Interlocked.Increment(ref owner.SqlCommands); + if (request.Hello is not null && owner._mode is "no-echo" or "unsolicited-reference") + { + request = request.Clone(); request.Hello.Capabilities.Clear(); Current = (T)(object)request; + } + } + return true; + } + } + private sealed class Writer(IServerStreamWriter inner, SessionInterceptor owner) : IServerStreamWriter + { + public WriteOptions? WriteOptions { get => inner.WriteOptions; set => inner.WriteOptions = value; } + public Task WriteAsync(T message) + { + if (message is not CommandSessionResponse response) return inner.WriteAsync(message); + response = response.Clone(); + if (response.Result is not null) + { + int number = Interlocked.Increment(ref owner._results); + if (number == 1) owner._first = response.Clone(); + if (owner._mode == "unknown-id" && number == 2) response.ResultSchemaId = 999; + if (owner._mode == "mixed-reference" && number == 2) response.Result.ColumnNames = owner._first!.Result.ColumnNames.Clone(); + if (owner._mode == "full-gap" && number == 1) response.ResultSchemaId = 7; + if (owner._mode == "unsolicited-reference" && number == 1) + { + response.ResultSchemaId = 1; response.ResultSchemaReference = true; + response.Result.ColumnNames = null; response.Result.ColumnTypes = null; + response.Result.ColumnNullability = null; response.Result.Columns.Clear(); + } + } + owner.Responses.Enqueue(response.Clone()); + return inner.WriteAsync((T)(object)response); + } + } + } +} diff --git a/tests/CSharpDB.Daemon.Tests/GrpcStreamingSessionTests.cs b/tests/CSharpDB.Daemon.Tests/GrpcStreamingSessionTests.cs new file mode 100644 index 00000000..9b601848 --- /dev/null +++ b/tests/CSharpDB.Daemon.Tests/GrpcStreamingSessionTests.cs @@ -0,0 +1,498 @@ +extern alias CSharpDbApi; + +using System.Collections.Concurrent; +using System.Reflection; +using System.Runtime.ExceptionServices; +using CSharpDbHostReadinessCoordinator = CSharpDbApi::CSharpDB.Api.CSharpDbHostReadinessCoordinator; +using CSharpDB.Client; +using CSharpDB.Client.Grpc; +using CSharpDB.Client.Models; +using CSharpDB.Testing; +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; +using Grpc.Core.Interceptors; +using Grpc.Net.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CSharpDB.Daemon.Tests; + +public sealed class GrpcStreamingSessionTests +{ + private static CSharpDbClientOptions Options(GrpcSocketDaemonFixture host, CSharpDbGrpcCallMode mode = CSharpDbGrpcCallMode.Streaming, + string? apiKey = null, CSharpDbRouteContext? route = null) => new() + { + Transport = CSharpDbTransport.Grpc, GrpcMode = CSharpDbGrpcMode.Native, GrpcCallMode = mode, + Endpoint = host.NativeEndpoint.AbsoluteUri, ApiKey = apiKey, RouteContext = route, + }; + + [Fact(Timeout = 30000)] + public async Task PooledCommands_ReuseOneStream_PreserveTransactionsAndUnaryMetadata() + { + var ct = TestContext.Current.CancellationToken; + await using var host = new GrpcSocketDaemonFixture(); + try + { + var options = Options(host); + await using (var client = await CSharpDbClient.RentAsync(options, 1, ct)) + { + Assert.Null((await client.ExecuteSqlAsync("CREATE TABLE stream_test (id INTEGER PRIMARY KEY, value DECIMAL(10,2)); INSERT INTO stream_test VALUES (1, 12.34)", ct)).Error); + var tx = await client.BeginTransactionAsync(ct); + Assert.Null((await client.ExecuteInTransactionAsync(tx.TransactionId, "UPDATE stream_test SET value = 99.50 WHERE id = 1", ct)).Error); + await client.RollbackTransactionAsync(tx.TransactionId, ct); + Assert.Equal(12.34m, (await client.ExecuteSqlAsync("SELECT value FROM stream_test WHERE id = 1", ct)).Rows![0][0]); + tx = await client.BeginTransactionAsync(ct); + Assert.Null((await client.ExecuteInTransactionAsync(tx.TransactionId, "UPDATE stream_test SET value = 22.50 WHERE id = 1", ct)).Error); + await client.CommitTransactionAsync(tx.TransactionId, ct); + Assert.Contains("stream_test", await client.GetTableNamesAsync(ct)); + } + await using (var again = await CSharpDbClient.RentAsync(options, 1, ct)) + Assert.Equal(22.50m, (await again.ExecuteSqlAsync("SELECT value FROM stream_test WHERE id = 1", ct)).Rows![0][0]); + Assert.Equal(1, Count(host, "CommandSession")); + Assert.Equal(1, Count(host, "GetTableNames")); + Assert.Equal(0, Count(host, "ExecuteSql")); + Assert.Equal(0, Count(host, "GetInfo")); + await CSharpDbClient.ClearPoolAsync(options.Endpoint!); + await Task.WhenAll(host.ConnectionClosures).WaitAsync(TimeSpan.FromSeconds(5), ct); + } + finally { await CSharpDbClient.ClearPoolAsync(host.NativeEndpoint.AbsoluteUri); } + } + + [Fact(Timeout = 30000)] + public async Task AbandonedTransaction_IsRolledBackBeforeNextRental() + { + var ct = TestContext.Current.CancellationToken; + await using var host = new GrpcSocketDaemonFixture(); + try + { + var options = Options(host); + await using (var first = await CSharpDbClient.RentAsync(options, 1, ct)) + { + Assert.Null((await first.ExecuteSqlAsync("CREATE TABLE stream_abandon (id INTEGER PRIMARY KEY)", ct)).Error); + var tx = await first.BeginTransactionAsync(ct); + Assert.Null((await first.ExecuteInTransactionAsync(tx.TransactionId, "INSERT INTO stream_abandon VALUES (1)", ct)).Error); + } + await using var next = await CSharpDbClient.RentAsync(options, 1, ct); + Assert.Equal(0L, (await next.ExecuteSqlAsync("SELECT COUNT(*) FROM stream_abandon", ct)).Rows![0][0]); + Assert.Equal(1, Count(host, "CommandSession")); + } + finally { await CSharpDbClient.ClearPoolAsync(host.NativeEndpoint.AbsoluteUri); } + } + + [Fact(Timeout = 30000)] + public async Task ConcurrentCalls_AreOrdered_AndStaleRentalIsRejected() + { + var ct = TestContext.Current.CancellationToken; + await using var host = new GrpcSocketDaemonFixture(); + try + { + var options = Options(host); + var client = await CSharpDbClient.RentAsync(options, 1, ct); + var reads = Enumerable.Range(0, 32).Select(i => client.ExecuteSqlAsync($"SELECT {i}", ct)).ToArray(); + var results = await Task.WhenAll(reads); + for (int i = 0; i < results.Length; i++) Assert.Equal((long)i, results[i].Rows![0][0]); + await client.DisposeAsync(); + await Assert.ThrowsAsync(() => client.ExecuteSqlAsync("SELECT 42", ct)); + await using var next = await CSharpDbClient.RentAsync(options, 1, ct); + Assert.Equal(42L, (await next.ExecuteSqlAsync("SELECT 42", ct)).Rows![0][0]); + Assert.Equal(1, Count(host, "CommandSession")); + } + finally { await CSharpDbClient.ClearPoolAsync(host.NativeEndpoint.AbsoluteUri); } + } + + [Fact(Timeout = 30000)] + public async Task CallModes_HaveSeparatePoolFamilies() + { + var ct = TestContext.Current.CancellationToken; + await using var host = new GrpcSocketDaemonFixture(); + try + { + await using var unary = await CSharpDbClient.RentAsync(Options(host, CSharpDbGrpcCallMode.Unary), 1, ct); + await using var stream = await CSharpDbClient.RentAsync(Options(host), 1, ct); + Assert.Equal(1, Count(host, "GetInfo")); + Assert.Equal(1, Count(host, "CommandSession")); + } + finally { await CSharpDbClient.ClearPoolAsync(host.NativeEndpoint.AbsoluteUri); } + } + + [Fact(Timeout = 30000)] + public async Task AuthenticationAndRoute_AreBoundToTheSession() + { + var ct = TestContext.Current.CancellationToken; + var control = new SessionControl { BlockFirstUnroutedGetInfo = true }; + await using var host = new GrpcSocketDaemonFixture("session-key", services => Decorate(services, control)); + using var startupDeadline = CancellationTokenSource.CreateLinkedTokenSource(ct); + startupDeadline.CancelAfter(TimeSpan.FromSeconds(15)); + try + { + await control.StartupInfoEntered.Task.WaitAsync(startupDeadline.Token); + Assert.False(control.Readiness.IsReady); + control.Routes.Clear(); // Reproduce the old clear before startup GetInfo records its route. + Assert.Empty(control.Routes); + control.StartupInfoRelease.TrySetResult(); + await control.StartupInfoRecorded.Task.WaitAsync(startupDeadline.Token); + Assert.Null(Assert.Single(control.Routes)); // The known startup probe can arrive after that clear. + await control.WaitForReadyAsync(startupDeadline.Token); + Assert.True(control.Readiness.IsReady); + control.Routes.Clear(); // Initialization has completed; no startup probe can now race this clear. + + var route = new CSharpDbRouteContext { Keyspace = "tenant", Key = "42" }; + await using var client = CSharpDbClient.Create(Options(host, apiKey: "session-key", route: route)); + Assert.Equal(42L, (await client.ExecuteSqlAsync("SELECT 42", ct)).Rows![0][0]); + Assert.NotEmpty(control.Routes); + Assert.All(control.Routes, actual => { Assert.Equal(route.Keyspace, actual?.Keyspace); Assert.Equal(route.Key, actual?.Key); }); + await using var rejected = CSharpDbClient.Create(Options(host, apiKey: "wrong-key")); + await Assert.ThrowsAsync(() => rejected.GetInfoAsync(ct)); + Assert.Equal(0, Count(host, "GetInfo")); + Assert.Equal(2, Count(host, "CommandSession")); + } + finally + { + control.StartupInfoRelease.TrySetResult(); + if (control.StartupInfoEntered.Task.IsCompleted) + // Finish released server work even if the test token was canceled. + await control.StartupInfoFinished.Task.WaitAsync(TimeSpan.FromSeconds(5), CancellationToken.None); + } + } + + [Theory(Timeout = 30000)] + [InlineData(false)] + [InlineData(true)] + public async Task UnsupportedVersionOrDuplicateRequestId_ClosesWithoutExecutingSql(bool duplicate) + { + var ct = TestContext.Current.CancellationToken; + await using var host = new GrpcSocketDaemonFixture(); + using var channel = GrpcChannel.ForAddress(host.NativeEndpoint); + using var call = new CSharpDbRpc.CSharpDbRpcClient(channel).CommandSession(cancellationToken: ct); + await call.RequestStream.WriteAsync(new CommandSessionRequest { Hello = new() { ProtocolVersion = duplicate ? 1u : 2u } }, ct); + if (duplicate) + { + Assert.True(await call.ResponseStream.MoveNext(ct)); + await call.RequestStream.WriteAsync(new CommandSessionRequest { RequestId = 1, GetInfo = new() }, ct); + Assert.True(await call.ResponseStream.MoveNext(ct)); + await call.RequestStream.WriteAsync(new CommandSessionRequest { RequestId = 1, ExecuteSql = new() { Sql = "SELECT 42" } }, ct); + } + var error = await Assert.ThrowsAsync(() => call.ResponseStream.MoveNext(ct)); + Assert.Equal(duplicate ? StatusCode.InvalidArgument : StatusCode.Unimplemented, error.StatusCode); + } + + [Fact(Timeout = 30000)] + public async Task RawSessions_CannotUseAnotherSessionsTransaction() + { + var ct = TestContext.Current.CancellationToken; + await using var host = new GrpcSocketDaemonFixture(); + using var channel = GrpcChannel.ForAddress(host.NativeEndpoint); + var rpc = new CSharpDbRpc.CSharpDbRpcClient(channel); + using var first = rpc.CommandSession(cancellationToken: ct); + using var second = rpc.CommandSession(cancellationToken: ct); + foreach (var call in new[] { first, second }) + { + await call.RequestStream.WriteAsync(new CommandSessionRequest { Hello = new() { ProtocolVersion = 1 } }, ct); + Assert.True(await call.ResponseStream.MoveNext(ct)); + } + await first.RequestStream.WriteAsync(new CommandSessionRequest { RequestId = 1, BeginTransaction = new() }, ct); + Assert.True(await first.ResponseStream.MoveNext(ct)); + string id = first.ResponseStream.Current.Transaction.TransactionId; + await second.RequestStream.WriteAsync(new CommandSessionRequest { RequestId = 1, ExecuteInTransaction = new() { TransactionId = id, Sql = "SELECT 42" } }, ct); + Assert.True(await second.ResponseStream.MoveNext(ct)); + Assert.Equal((int)StatusCode.PermissionDenied, second.ResponseStream.Current.Error.StatusCode); + await first.RequestStream.WriteAsync(new CommandSessionRequest { RequestId = 2, RollbackTransaction = new() { TransactionId = id } }, ct); + Assert.True(await first.ResponseStream.MoveNext(ct)); + Assert.NotNull(first.ResponseStream.Current.Completed); + } + + [Theory(Timeout = 30000)] + [InlineData("BeginTransactionAsync")] + [InlineData("CommitTransactionAsync")] + public async Task LostTransactionResponse_IsNotReplayed_AndLateBeginIsCleaned(string method) + { + var ct = TestContext.Current.CancellationToken; + var control = new SessionControl { BlockAfter = method }; + await using var host = new GrpcSocketDaemonFixture(configureServices: services => Decorate(services, control)); + var options = Options(host); + try + { + var client = await CSharpDbClient.RentAsync(options, 1, ct); + Assert.Null((await client.ExecuteSqlAsync("CREATE TABLE stream_lost (id INTEGER PRIMARY KEY)", ct)).Error); + string? transaction = null; + if (method == nameof(ICSharpDbClient.CommitTransactionAsync)) + { + transaction = (await client.BeginTransactionAsync(ct)).TransactionId; + Assert.Null((await client.ExecuteInTransactionAsync(transaction, "INSERT INTO stream_lost VALUES (1)", ct)).Error); + } + using var cancel = CancellationTokenSource.CreateLinkedTokenSource(ct); + Task operation = transaction is null ? client.BeginTransactionAsync(cancel.Token) : client.CommitTransactionAsync(transaction, cancel.Token); + await control.Entered.Task.WaitAsync(TimeSpan.FromSeconds(10), ct); + cancel.Cancel(); + await Assert.ThrowsAnyAsync(() => operation.WaitAsync(TimeSpan.FromSeconds(5), ct)); + control.Release.TrySetResult(); + if (transaction is null) await control.RolledBack.Task.WaitAsync(TimeSpan.FromSeconds(10), ct); + await client.DisposeAsync(); + await using var next = await CSharpDbClient.RentAsync(options, 1, ct); + Assert.Equal(transaction is null ? 0L : 1L, (await next.ExecuteSqlAsync("SELECT COUNT(*) FROM stream_lost", ct)).Rows![0][0]); + Assert.Equal(1, control.Count(method)); + Assert.Equal(2, Count(host, "CommandSession")); + } + finally { control.Release.TrySetResult(); await CSharpDbClient.ClearPoolAsync(host.NativeEndpoint.AbsoluteUri); } + } + + [Fact(Timeout = 30000)] + public async Task CanceledQueueWaiter_DoesNotCancelActiveCommand() + { + var ct = TestContext.Current.CancellationToken; + var control = new SessionControl { BlockBeforeSql = true }; + await using var host = new GrpcSocketDaemonFixture(configureServices: services => Decorate(services, control)); + try + { + await using var client = await CSharpDbClient.RentAsync(Options(host), 1, ct); + var active = client.ExecuteSqlAsync("SELECT 42", ct); + await control.Entered.Task.WaitAsync(TimeSpan.FromSeconds(10), ct); + using var cancel = CancellationTokenSource.CreateLinkedTokenSource(ct); + var waiting = client.GetInfoAsync(cancel.Token); + cancel.Cancel(); + await Assert.ThrowsAnyAsync(() => waiting); + Assert.False(active.IsCompleted); + control.Release.TrySetResult(); + Assert.Equal(42L, (await active.WaitAsync(TimeSpan.FromSeconds(5), ct)).Rows![0][0]); + } + finally { control.Release.TrySetResult(); await CSharpDbClient.ClearPoolAsync(host.NativeEndpoint.AbsoluteUri); } + } + + [Theory(Timeout = 30000)] + [InlineData(false)] + [InlineData(true)] + public async Task RawDisconnect_RollsBackSessionTransactions(bool graceful) + { + var ct = TestContext.Current.CancellationToken; + var control = new SessionControl(); + await using var host = new GrpcSocketDaemonFixture(configureServices: services => Decorate(services, control)); + using var channel = GrpcChannel.ForAddress(host.NativeEndpoint); + using var call = new CSharpDbRpc.CSharpDbRpcClient(channel).CommandSession(cancellationToken: ct); + await call.RequestStream.WriteAsync(new CommandSessionRequest { Hello = new() { ProtocolVersion = 1 } }, ct); + Assert.True(await call.ResponseStream.MoveNext(ct)); + await call.RequestStream.WriteAsync(new CommandSessionRequest { RequestId = 1, BeginTransaction = new() }, ct); + Assert.True(await call.ResponseStream.MoveNext(ct)); + await call.RequestStream.WriteAsync(new CommandSessionRequest { RequestId = 2, ExecuteInTransaction = new() + { TransactionId = call.ResponseStream.Current.Transaction.TransactionId, Sql = "CREATE TABLE disconnected (id INTEGER PRIMARY KEY)" } }, ct); + Assert.True(await call.ResponseStream.MoveNext(ct)); + Assert.Null(call.ResponseStream.Current.Error); + if (graceful) + { + await call.RequestStream.CompleteAsync(); + Assert.False(await call.ResponseStream.MoveNext(ct)); + } + else call.Dispose(); + await control.RolledBack.Task.WaitAsync(TimeSpan.FromSeconds(10), ct); + await using var verify = host.CreateClient(CSharpDbGrpcMode.Native); + Assert.DoesNotContain("disconnected", await verify.GetTableNamesAsync(ct)); + Assert.Equal(1, control.Count(nameof(ICSharpDbClient.BeginTransactionAsync))); + Assert.Equal(1, control.Count(nameof(ICSharpDbClient.RollbackTransactionAsync))); + } + + [Theory(Timeout = 30000)] + [InlineData(false)] + [InlineData(true)] + public async Task ActiveCancellationOrOwnerDisposal_ClosesSessionAndRollsBack(bool dispose) + { + var ct = TestContext.Current.CancellationToken; + var control = new SessionControl(); + await using var host = new GrpcSocketDaemonFixture(configureServices: services => Decorate(services, control)); + await using var client = CSharpDbClient.Create(Options(host)); + await client.BeginTransactionAsync(ct); + control.BlockBeforeSql = true; + using var cancel = CancellationTokenSource.CreateLinkedTokenSource(ct); + var active = client.ExecuteSqlAsync("SELECT 42", cancel.Token); + await control.Entered.Task.WaitAsync(TimeSpan.FromSeconds(10), ct); + if (dispose) await client.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(5), ct); + else cancel.Cancel(); + await Assert.ThrowsAnyAsync(() => active.WaitAsync(TimeSpan.FromSeconds(5), ct)); + await control.RolledBack.Task.WaitAsync(TimeSpan.FromSeconds(10), ct); + Assert.Equal(1, Count(host, "CommandSession")); + Assert.Equal(1, control.Count(nameof(ICSharpDbClient.ExecuteSqlAsync))); + if (!dispose) await Assert.ThrowsAsync(() => client.GetInfoAsync(ct)); + } + + [Theory(Timeout = 30000)] + [InlineData("id")] + [InlineData("type")] + [InlineData("error")] + public async Task MalformedResponse_DiscardsSessionWithoutReplay(string corruption) + { + var ct = TestContext.Current.CancellationToken; + var control = new SessionControl(); + await using var host = new GrpcSocketDaemonFixture(configureServices: services => + { + Decorate(services, control); + services.AddSingleton(new CorruptSessionInterceptor(corruption)); + services.AddGrpc(options => options.Interceptors.Add()); + }); + var options = Options(host); + try + { + await using (var client = await CSharpDbClient.RentAsync(options, 1, ct)) + await Assert.ThrowsAsync(() => client.ExecuteSqlAsync("SELECT 42", ct)); + Assert.Equal(1, control.Count(nameof(ICSharpDbClient.ExecuteSqlAsync))); + await using var replacement = await CSharpDbClient.RentAsync(options, 1, ct); + Assert.Equal(42L, (await replacement.ExecuteSqlAsync("SELECT 42", ct)).Rows![0][0]); + Assert.Equal(2, Count(host, "CommandSession")); + Assert.Equal(2, control.Count(nameof(ICSharpDbClient.ExecuteSqlAsync))); + } + finally { await CSharpDbClient.ClearPoolAsync(host.NativeEndpoint.AbsoluteUri); } + } + + [Theory] + [InlineData(CSharpDbTransport.Direct, CSharpDbGrpcMode.Native, CSharpDbGrpcCallMode.Streaming)] + [InlineData(CSharpDbTransport.Grpc, CSharpDbGrpcMode.Auto, CSharpDbGrpcCallMode.Streaming)] + [InlineData(CSharpDbTransport.Grpc, CSharpDbGrpcMode.Native, (CSharpDbGrpcCallMode)42)] + public void InvalidStreamingConfiguration_IsRejected(CSharpDbTransport transport, CSharpDbGrpcMode mode, CSharpDbGrpcCallMode calls) + => Assert.Throws(() => CSharpDbClient.Create(new() + { Transport = transport, Endpoint = "http://localhost", GrpcMode = mode, GrpcCallMode = calls })); + + [Fact(Timeout = 30000)] + public async Task CommandError_PreservesDatabaseCode_AndDirectSessionRemainsUsable() + { + var ct = TestContext.Current.CancellationToken; + var control = new SessionControl { SqlError = new CSharpDB.Primitives.CSharpDbException(CSharpDB.Primitives.ErrorCode.Busy, "test busy") }; + await using var host = new GrpcSocketDaemonFixture(configureServices: services => Decorate(services, control)); + await using var client = CSharpDbClient.Create(Options(host)); + var error = await Assert.ThrowsAsync(() => client.ExecuteSqlAsync("SELECT 42", ct)); + Assert.Equal(CSharpDB.Primitives.ErrorCode.Busy, error.Code); + var rpc = Assert.IsType(error.InnerException); + Assert.Equal(StatusCode.Aborted, rpc.StatusCode); + Assert.Equal("Busy", rpc.Trailers.GetValue("csharpdb-error-code")); + control.SqlError = null; + Assert.Equal(42L, (await client.ExecuteSqlAsync("SELECT 42", ct)).Rows![0][0]); + Assert.Equal(1, Count(host, "CommandSession")); + } + + private static long Count(GrpcSocketDaemonFixture host, string method) + => host.Snapshot().Where(x => x.Key.Method.EndsWith("/" + method, StringComparison.Ordinal)).Sum(x => x.Value); + + private static void Decorate(IServiceCollection services, SessionControl control) + { + var original = services.Last(x => x.ServiceType == typeof(ICSharpDbClient)); + services.Replace(ServiceDescriptor.Singleton(provider => + { + var real = (ICSharpDbClient)original.ImplementationFactory!(provider); + var proxy = DispatchProxy.Create(); + var state = (SessionProxy)(object)proxy; + state.Inner = real; state.Control = control; + state.Route = provider.GetRequiredService(); + control.Readiness = provider.GetRequiredService(); + return proxy; + })); + } + + public sealed class SessionControl + { + public string? BlockAfter { get; init; } + public bool BlockBeforeSql { get; set; } + public Exception? SqlError { get; set; } + public bool BlockFirstUnroutedGetInfo { get; init; } + public int StartupInfoIntercepted; + public CSharpDbHostReadinessCoordinator Readiness { get; set; } = null!; + public TaskCompletionSource StartupInfoEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource StartupInfoRelease { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource StartupInfoRecorded { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource StartupInfoFinished { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource RolledBack { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public ConcurrentQueue Routes { get; } = new(); + public ConcurrentDictionary Calls { get; } = new(); + public int Count(string method) => Calls.GetValueOrDefault(method); + + public async Task WaitForReadyAsync(CancellationToken ct) + { + try + { + while (!Readiness.IsReady) + { + ct.ThrowIfCancellationRequested(); + await Task.Yield(); + } + } + catch (OperationCanceledException error) when (ct.IsCancellationRequested) + { + throw new TimeoutException($"Host readiness did not complete; last state: {Readiness.Snapshot}", error); + } + } + } + + public sealed class CorruptSessionInterceptor(string corruption) : Interceptor + { + private readonly string _corruption = corruption; + private int _corrupted; + public override Task DuplexStreamingServerHandler(IAsyncStreamReader reader, + IServerStreamWriter writer, ServerCallContext context, DuplexStreamingServerMethod continuation) + => continuation(reader, new CorruptWriter(writer, this), context); + + private sealed class CorruptWriter(IServerStreamWriter inner, CorruptSessionInterceptor owner) : IServerStreamWriter + { + public WriteOptions? WriteOptions { get => inner.WriteOptions; set => inner.WriteOptions = value; } + public Task WriteAsync(T message) + { + if (message is CommandSessionResponse { Result: not null } response && Interlocked.Exchange(ref owner._corrupted, 1) == 0) + { + response = response.Clone(); + switch (owner._corruption) + { + case "id": response.RequestId++; break; + case "type": response.Completed = new Empty(); break; + case "error": response.Error = new() { StatusCode = 0, Detail = "invalid" }; break; + } + return inner.WriteAsync((T)(object)response); + } + return inner.WriteAsync(message); + } + } + } + + public class SessionProxy : DispatchProxy + { + public ICSharpDbClient Inner = null!; + public SessionControl Control = null!; + public ICSharpDbRouteContextAccessor Route = null!; + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + var method = targetMethod!; + Control.Calls.AddOrUpdate(method.Name, 1, (_, count) => count + 1); + if (method.Name == nameof(ICSharpDbClient.GetInfoAsync) && Control.BlockFirstUnroutedGetInfo && + Route.Current is null && Interlocked.CompareExchange(ref Control.StartupInfoIntercepted, 1, 0) == 0) + return BlockStartupInfoAsync((CancellationToken)args![0]!); + if (method.Name is nameof(ICSharpDbClient.GetInfoAsync) or nameof(ICSharpDbClient.ExecuteSqlAsync)) + Control.Routes.Enqueue(Route.Current); + if (method.Name == nameof(ICSharpDbClient.ExecuteSqlAsync) && Control.SqlError is { } sqlError) throw sqlError; + if (method.Name == nameof(ICSharpDbClient.ExecuteSqlAsync) && Control.BlockBeforeSql) + return BlockSqlAsync((string)args![0]!, (CancellationToken)args[1]!); + object? result; + try { result = method.Invoke(Inner, args); } + catch (TargetInvocationException error) when (error.InnerException is not null) + { ExceptionDispatchInfo.Capture(error.InnerException).Throw(); throw; } + if (method.Name == Control.BlockAfter) + return result is Task begin ? AfterBeginAsync(begin) : AfterAsync((Task)result!); + if (method.Name == nameof(ICSharpDbClient.RollbackTransactionAsync)) return ObserveRollbackAsync((Task)result!); + return result; + } + private async Task BlockStartupInfoAsync(CancellationToken ct) + { + Control.StartupInfoEntered.TrySetResult(); + try + { + await Control.StartupInfoRelease.Task.WaitAsync(ct); + Control.Routes.Enqueue(Route.Current); + Control.StartupInfoRecorded.TrySetResult(); + return await Inner.GetInfoAsync(ct); + } + finally { Control.StartupInfoFinished.TrySetResult(); } + } + private async Task BlockSqlAsync(string sql, CancellationToken ct) + { Control.Entered.TrySetResult(); await Control.Release.Task.WaitAsync(ct); return await Inner.ExecuteSqlAsync(sql, ct); } + private async Task AfterBeginAsync(Task task) + { var result = await task; Control.Entered.TrySetResult(); await Control.Release.Task; return result; } + private async Task AfterAsync(Task task) + { await task; Control.Entered.TrySetResult(); await Control.Release.Task; } + private async Task ObserveRollbackAsync(Task task) + { await task; Control.RolledBack.TrySetResult(); } + } +} diff --git a/tests/CSharpDB.Daemon.Tests/NativeGrpcSocketTests.cs b/tests/CSharpDB.Daemon.Tests/NativeGrpcSocketTests.cs new file mode 100644 index 00000000..bfca6fca --- /dev/null +++ b/tests/CSharpDB.Daemon.Tests/NativeGrpcSocketTests.cs @@ -0,0 +1,96 @@ +using CSharpDB.Client; +using CSharpDB.Testing; + +namespace CSharpDB.Daemon.Tests; + +public sealed class NativeGrpcSocketTests +{ + [Theory] + [InlineData(CSharpDbGrpcMode.Auto, "HTTP/1.1", "application/grpc-web")] + [InlineData(CSharpDbGrpcMode.Native, "HTTP/2", "application/grpc")] + public async Task SqlAndTransactions_UseExpectedWireProtocol( + CSharpDbGrpcMode mode, string protocol, string contentType) + { + var ct = TestContext.Current.CancellationToken; + await using var host = new GrpcSocketDaemonFixture(); + await using var client = host.CreateClient(mode); + Assert.Null((await client.ExecuteSqlAsync( + "CREATE TABLE native_test (id INTEGER PRIMARY KEY, amount DECIMAL(10,2)); INSERT INTO native_test VALUES (1, 12.34);", ct)).Error); + var transaction = await client.BeginTransactionAsync(ct); + Assert.Null((await client.ExecuteInTransactionAsync(transaction.TransactionId, + "UPDATE native_test SET amount = 99.50 WHERE id = 1", ct)).Error); + await client.RollbackTransactionAsync(transaction.TransactionId, ct); + var result = await client.ExecuteSqlAsync("SELECT amount FROM native_test WHERE id = 1", ct); + Assert.Null(result.Error); + Assert.Equal(12.34m, Assert.Single(Assert.Single(result.Rows!))); + Assert.All(host.Snapshot(), pair => + { + Assert.Equal(protocol, pair.Key.Protocol); + Assert.StartsWith(contentType, pair.Key.ContentType, StringComparison.Ordinal); + if (mode == CSharpDbGrpcMode.Native) + Assert.DoesNotContain("grpc-web", pair.Key.ContentType, StringComparison.Ordinal); + }); + Assert.NotEmpty(host.Snapshot()); + } + + [Fact] + public async Task NativeMode_DoesNotFallBackToHttp1() + { + await using var host = new GrpcSocketDaemonFixture(); + await using var client = CSharpDbClient.Create(new CSharpDbClientOptions + { + Transport = CSharpDbTransport.Grpc, + GrpcMode = CSharpDbGrpcMode.Native, + Endpoint = host.CompatibilityEndpoint.AbsoluteUri, + }); + await Assert.ThrowsAsync( + () => client.GetInfoAsync(TestContext.Current.CancellationToken)); + Assert.Empty(host.Snapshot()); + } + + [Fact] + public async Task NativeMode_CustomHttpClientRemainsCallerOwned() + { + await using var host = new GrpcSocketDaemonFixture(); + using var httpClient = new HttpClient { DefaultRequestVersion = System.Net.HttpVersion.Version11 }; + await using (var client = CSharpDbClient.Create(new CSharpDbClientOptions + { + Transport = CSharpDbTransport.Grpc, + GrpcMode = CSharpDbGrpcMode.Native, + Endpoint = host.NativeEndpoint.AbsoluteUri, + HttpClient = httpClient, + })) + Assert.NotNull(await client.GetInfoAsync(TestContext.Current.CancellationToken)); + using var request = new HttpRequestMessage(HttpMethod.Get, host.NativeEndpoint) + { + Version = System.Net.HttpVersion.Version20, + VersionPolicy = HttpVersionPolicy.RequestVersionExact, + }; + using var response = await httpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(System.Net.HttpVersion.Version20, response.Version); + Assert.All(host.Snapshot(), pair => Assert.Equal("HTTP/2", pair.Key.Protocol)); + } + + [Fact] + public async Task NativeMode_ApiKeyAuthorizationStillApplies() + { + await using var host = new GrpcSocketDaemonFixture("socket-test-key"); + await using var authorized = host.CreateClient(CSharpDbGrpcMode.Native, "socket-test-key"); + Assert.NotNull(await authorized.GetInfoAsync(TestContext.Current.CancellationToken)); + await using var unauthorized = host.CreateClient(CSharpDbGrpcMode.Native); + await Assert.ThrowsAsync( + () => unauthorized.GetInfoAsync(TestContext.Current.CancellationToken)); + } + + [Theory] + [InlineData(CSharpDbTransport.Direct, "test.db")] + [InlineData(CSharpDbTransport.Http, "http://localhost")] + public void NativeMode_RejectsOtherTransports(CSharpDbTransport transport, string endpoint) + => Assert.Throws(() => CSharpDbClient.Create( + new CSharpDbClientOptions { Transport = transport, Endpoint = endpoint, GrpcMode = CSharpDbGrpcMode.Native })); + + [Fact] + public void UnknownMode_IsRejected() + => Assert.Throws(() => CSharpDbClient.Create( + new CSharpDbClientOptions { Transport = CSharpDbTransport.Grpc, Endpoint = "http://localhost", GrpcMode = (CSharpDbGrpcMode)42 })); +} diff --git a/tests/CSharpDB.Daemon.Tests/SdkGrpcPoolingTests.cs b/tests/CSharpDB.Daemon.Tests/SdkGrpcPoolingTests.cs new file mode 100644 index 00000000..154afc81 --- /dev/null +++ b/tests/CSharpDB.Daemon.Tests/SdkGrpcPoolingTests.cs @@ -0,0 +1,399 @@ +using CSharpDB.Client; +using CSharpDB.Client.Internal; +using CSharpDB.Client.Models; +using CSharpDB.Testing; +using Grpc.Core; +using Grpc.Core.Interceptors; +using Empty = Google.Protobuf.WellKnownTypes.Empty; + +namespace CSharpDB.Daemon.Tests; + +public sealed class SdkGrpcPoolingTests : IAsyncLifetime +{ + private GrpcSocketDaemonFixture _host = null!; + private static CancellationToken Ct => TestContext.Current.CancellationToken; + private CSharpDbClientOptions Options(CSharpDbGrpcMode mode = CSharpDbGrpcMode.Auto, HttpClient? httpClient = null) => new() + { + Transport = CSharpDbTransport.Grpc, GrpcMode = mode, + Endpoint = _host.EndpointFor(mode).AbsoluteUri, HttpClient = httpClient, + }; + + public ValueTask InitializeAsync() + { + _host = new GrpcSocketDaemonFixture(); + return ValueTask.CompletedTask; + } + public async ValueTask DisposeAsync() + { + await CSharpDbClient.ClearPoolAsync(_host.CompatibilityEndpoint.AbsoluteUri); + await CSharpDbClient.ClearPoolAsync(_host.NativeEndpoint.AbsoluteUri); + await _host.DisposeAsync(); + } + + [Theory] + [InlineData(CSharpDbGrpcMode.Auto)] + [InlineData(CSharpDbGrpcMode.Native)] + public async Task RentDispose_ReusesChannel_ButDisposedRentalCannotIssueAnyRpc(CSharpDbGrpcMode mode) + { + var first = await CSharpDbClient.RentAsync(Options(mode), 1, Ct); + AssertRead(await first.ExecuteSqlAsync("SELECT 42", Ct)); + await first.DisposeAsync(); + await using var second = await CSharpDbClient.RentAsync(Options(mode), 1, Ct); + Assert.NotSame(first, second); + AssertRead(await second.ExecuteSqlAsync("SELECT 42", Ct)); + Assert.Equal(1, Count("GetInfo")); + await Assert.ThrowsAsync(() => first.ExecuteSqlAsync("SELECT 42", Ct)); + await Assert.ThrowsAsync(() => first.GetTableNamesAsync(Ct)); + await Assert.ThrowsAsync(() => first.GetInfoAsync(Ct)); + await first.DisposeAsync(); + Assert.Equal(2, Count("ExecuteSql")); + Assert.Equal(0, Count("GetTableNames")); + } + + [Fact] + public async Task ReusedOptions_PreserveCapacityIsolationAndRetirement() + { + var options = Options(); + await using var single = await CSharpDbClient.RentAsync(options, 1, Ct); + await using var first = await CSharpDbClient.RentAsync(options, 2, Ct); + await using var second = await CSharpDbClient.RentAsync(options, 2, Ct); + Assert.Equal(3, Count("GetInfo")); + using var cancellation = new CancellationTokenSource(); + var waiting = CSharpDbClient.RentAsync(options, 1, cancellation.Token).AsTask(); + Assert.False(waiting.IsCompleted); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => waiting); + await CSharpDbClient.ClearPoolAsync(options.Endpoint!); + await using var fresh = await CSharpDbClient.RentAsync(options, 1, Ct); + AssertRead(await fresh.ExecuteSqlAsync("SELECT 42", Ct)); + AssertRead(await single.ExecuteSqlAsync("SELECT 42", Ct)); + Assert.Equal(4, Count("GetInfo")); + } + + [Fact] + public async Task PoolCapacity_WaitsCancels_AndClearWakesWaiterWithoutClosingActiveRental() + { + await using var owner = await CSharpDbClient.RentAsync(Options(), 1, Ct); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(Ct); + Task canceled = CSharpDbClient.RentAsync(Options(), 1, cancellation.Token).AsTask(); + Assert.False(canceled.IsCompleted); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => canceled); + Task waiting = CSharpDbClient.RentAsync(Options(), 1, Ct).AsTask(); + Assert.False(waiting.IsCompleted); + await CSharpDbClient.ClearPoolAsync(_host.CompatibilityEndpoint.AbsoluteUri); + await using var admitted = await waiting.WaitAsync(TimeSpan.FromSeconds(10), Ct); + AssertRead(await owner.ExecuteSqlAsync("SELECT 42", Ct)); + Assert.Equal(2, Count("GetInfo")); + } + + [Fact] + public async Task Dispose_RollsBackUnfinishedTransaction_BeforeNextRental() + { + var owner = await CSharpDbClient.RentAsync(Options(), 1, Ct); + Assert.Null((await owner.ExecuteSqlAsync("CREATE TABLE sdk_tx (id INTEGER PRIMARY KEY)", Ct)).Error); + var transaction = await owner.BeginTransactionAsync(Ct); + Assert.Null((await owner.ExecuteInTransactionAsync(transaction.TransactionId, "INSERT INTO sdk_tx VALUES (1)", Ct)).Error); + await owner.DisposeAsync(); + await using var next = await CSharpDbClient.RentAsync(Options(), 1, Ct); + var result = await next.ExecuteSqlAsync("SELECT COUNT(*) FROM sdk_tx", Ct); + Assert.Null(result.Error); + Assert.Equal(0L, result.Rows![0][0]); + await Assert.ThrowsAsync(() => next.CommitTransactionAsync(transaction.TransactionId, Ct)); + Assert.Equal(1, Count("RollbackTransaction")); + Assert.Equal(0, Count("CommitTransaction")); + Assert.Equal(1, Count("GetInfo")); + } + + [Fact] + public async Task Commit_CompletesTransaction_WithoutDisposalRollback() + { + await using (var client = await CSharpDbClient.RentAsync(Options(), 1, Ct)) + { + var transaction = await client.BeginTransactionAsync(Ct); + await client.CommitTransactionAsync(transaction.TransactionId, Ct); + } + Assert.Equal(1, Count("CommitTransaction")); + Assert.Equal(0, Count("RollbackTransaction")); + } + + [Fact] + public async Task ForeignTransactionId_IsRejected_WhileBothRentalsAreLive() + { + await using var first = await CSharpDbClient.RentAsync(Options(), 2, Ct); + await using var second = await CSharpDbClient.RentAsync(Options(), 2, Ct); + var transaction = await first.BeginTransactionAsync(Ct); + await Assert.ThrowsAsync(() => second.ExecuteInTransactionAsync(transaction.TransactionId, "SELECT 42", Ct)); + await Assert.ThrowsAsync(() => second.RollbackTransactionAsync(transaction.TransactionId, Ct)); + Assert.Equal(0, Count("ExecuteInTransaction")); + Assert.Equal(0, Count("RollbackTransaction")); + await first.RollbackTransactionAsync(transaction.TransactionId, Ct); + } + + [Fact] + public async Task CanceledRpc_DiscardsConnection_AndDoesNotReplaySql() + { + var first = await CSharpDbClient.RentAsync(Options(), 1, Ct); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => first.ExecuteSqlAsync("SELECT 42", cancellation.Token)); + await first.DisposeAsync(); + long before = Count("ExecuteSql"); + await using var second = await CSharpDbClient.RentAsync(Options(), 1, Ct); + Assert.Equal(2, Count("GetInfo")); + Assert.Equal(before, Count("ExecuteSql")); + } + + [Theory] + [InlineData("CommitTransaction")] + [InlineData("RollbackTransaction")] + public async Task FailedTransactionRpc_IsNotReplayed_AndConnectionIsDiscarded(string failedMethod) + { + using var handler = new ControlledHandler(); + using var httpClient = new HttpClient(handler); + var options = Options(CSharpDbGrpcMode.Native, httpClient); + var first = await CSharpDbClient.RentAsync(options, 1, Ct); + var transaction = await first.BeginTransactionAsync(Ct); + handler.FailMethod = failedMethod; + if (failedMethod == "CommitTransaction") + await Assert.ThrowsAsync(() => first.CommitTransactionAsync(transaction.TransactionId, Ct)); + else await Assert.ThrowsAsync(() => first.RollbackTransactionAsync(transaction.TransactionId, Ct)); + handler.FailMethod = null; + await first.DisposeAsync(); + Assert.Equal(1, handler.Failures); + Assert.Equal(0, Count(failedMethod)); + Assert.Equal(failedMethod == "CommitTransaction" ? 1 : 0, Count("RollbackTransaction")); + if (failedMethod == "RollbackTransaction") + { + // Test teardown explicitly releases the server transaction whose + // rollback was intercepted. The rental itself did not replay it. + await using var cleanup = CSharpDbClient.Create(options); + await cleanup.RollbackTransactionAsync(transaction.TransactionId, Ct); + } + await using var next = await CSharpDbClient.RentAsync(options, 1, Ct); + Assert.Equal(2, Count("GetInfo")); + } + + [Theory] + [InlineData("ExecuteSql")] + [InlineData("BeginTransaction")] + public async Task Disposal_DrainsOutstandingRpc_AndResetsTransactionThatCompletesDuringDisposal(string blockedMethod) + { + using var handler = new ControlledHandler { BlockMethod = blockedMethod }; + using var httpClient = new HttpClient(handler); + var options = Options(CSharpDbGrpcMode.Native, httpClient); + var first = await CSharpDbClient.RentAsync(options, 1, Ct); + Task operation = blockedMethod == "ExecuteSql" + ? first.ExecuteSqlAsync("SELECT 42", Ct) : first.BeginTransactionAsync(Ct); + await handler.Entered.Task.WaitAsync(TimeSpan.FromSeconds(10), Ct); + Task disposed = first.DisposeAsync().AsTask(); + Task alsoDisposed = first.DisposeAsync().AsTask(); + Assert.Same(disposed, alsoDisposed); + Task waiting = CSharpDbClient.RentAsync(options, 1, Ct).AsTask(); + Assert.False(disposed.IsCompleted); + Assert.False(waiting.IsCompleted); + await Assert.ThrowsAsync(() => first.GetInfoAsync(Ct)); + handler.Resume.SetResult(); + await operation.WaitAsync(TimeSpan.FromSeconds(10), Ct); + await disposed.WaitAsync(TimeSpan.FromSeconds(10), Ct); + await alsoDisposed; + await using var next = await waiting.WaitAsync(TimeSpan.FromSeconds(10), Ct); + Assert.Equal(1, Count("GetInfo")); + Assert.Equal(blockedMethod == "BeginTransaction" ? 1 : 0, Count("RollbackTransaction")); + AssertRead(await next.ExecuteSqlAsync("SELECT 42", Ct)); + } + + [Fact] + public async Task RentedClient_PreservesMetadataAndDefinitionCapabilities() + { + await using var client = await CSharpDbClient.RentAsync(Options(), 1, Ct); + var catalog = Assert.IsAssignableFrom(client); + Assert.NotNull(await catalog.ReadDefinitionCatalogAsync(ct: Ct)); + Assert.IsAssignableFrom(client); + Assert.NotNull(await client.GetTableNamesAsync(Ct)); + } + + [Fact] + public async Task Rent_ValidatesConfigurationEvenWhenPoolIsWarm() + { + await using (var client = await CSharpDbClient.RentAsync(Options(), 1, Ct)) { } + await Assert.ThrowsAsync(() => CSharpDbClient.RentAsync(new CSharpDbClientOptions + { + Transport = CSharpDbTransport.Grpc, Endpoint = _host.CompatibilityEndpoint.AbsoluteUri, DataSource = "forbidden.db", + }, 1, Ct).AsTask()); + await Assert.ThrowsAsync(() => CSharpDbClient.RentAsync(new CSharpDbClientOptions + { + Transport = CSharpDbTransport.Http, Endpoint = _host.CompatibilityEndpoint.AbsoluteUri, + }, 1, Ct).AsTask()); + await Assert.ThrowsAsync(() => CSharpDbClient.RentAsync(Options(), 0, Ct).AsTask()); + Assert.Equal(1, Count("GetInfo")); + } + + [Fact] + public async Task IdleRetirement_RequiresAllRentalsReturned_AndClosesTheFamily() + { + var pool = new GrpcClientPool(Options(), 1); + try + { + var client = await pool.TryRentAsync(Ct); + Assert.NotNull(client); + Assert.False(pool.TryRetireIdle(TimeSpan.Zero)); + await client.DisposeAsync(); + Assert.True(pool.TryRetireIdle(TimeSpan.Zero)); + Assert.Null(await pool.TryRentAsync(Ct)); + } + finally { await pool.RetireAsync(); } + } + + [Fact] + public async Task ConcurrentReads_OnOneRental_CompleteBeforeConnectionReturnsToPool() + { + var first = await CSharpDbClient.RentAsync(Options(CSharpDbGrpcMode.Native), 1, Ct); + Task[] reads = Enumerable.Range(0, 16).Select(_ => first.ExecuteSqlAsync("SELECT 42", Ct)).ToArray(); + Task disposed = first.DisposeAsync().AsTask(); + foreach (var result in await Task.WhenAll(reads)) AssertRead(result); + await disposed; + await using var next = await CSharpDbClient.RentAsync(Options(CSharpDbGrpcMode.Native), 1, Ct); + AssertRead(await next.ExecuteSqlAsync("SELECT 42", Ct)); + Assert.Equal(1, Count("GetInfo")); + Assert.Equal(17, Count("ExecuteSql")); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CompletedRental_DisposeOnce_AndAllDisposersAwaitReturn(bool delayed) + { + await using var owner = new GrpcTransportClient(_host.CompatibilityEndpoint); + var returned = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int returns = 0; + var lease = new GrpcClientLease(owner, (client, healthy) => + { + Assert.Same(owner, client); + Assert.True(healthy); + Interlocked.Increment(ref returns); + return delayed ? new ValueTask(returned.Task) : ValueTask.CompletedTask; + }); + Task first = lease.DisposeAsync().AsTask(); + Task second = lease.DisposeAsync().AsTask(); + Assert.Same(first, second); + Assert.Equal(!delayed, first.IsCompletedSuccessfully); + Assert.Equal(1, returns); + returned.SetResult(); + await Task.WhenAll(first, second); + await lease.DisposeAsync(); + Assert.Equal(1, returns); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CompletedRental_ReturnFailure_IsSharedWithoutRetry(bool asynchronous) + { + await using var owner = new GrpcTransportClient(_host.CompatibilityEndpoint); + var failure = new InvalidOperationException("Injected return failure"); + var returned = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int returns = 0; + var lease = new GrpcClientLease(owner, (_, _) => + { + returns++; + if (!asynchronous) throw failure; + return new ValueTask(returned.Task); + }); + Task first = lease.DisposeAsync().AsTask(); + Task second = lease.DisposeAsync().AsTask(); + if (asynchronous) returned.SetException(failure); + Assert.Same(first, second); + Assert.Same(failure, await Record.ExceptionAsync(() => first)); + Assert.Same(failure, await Record.ExceptionAsync(() => second)); + Assert.Equal(1, returns); + } + + [Fact] + public async Task RentalInterceptor_ForwardsMetadataStatusAndDisposal_WithoutEagerHeaders() + { + await using var owner = new GrpcTransportClient(_host.CompatibilityEndpoint); + var lease = new GrpcClientLease(owner, (_, _) => ValueTask.CompletedTask); + var headers = new Metadata { { "test-header", "header" } }; + var trailers = new Metadata { { "test-trailer", "trailer" } }; + int headerReads = 0, callDisposals = 0; + var marshaller = Marshallers.Create(_ => [], _ => new Empty()); + var context = new ClientInterceptorContext( + new Method(MethodType.Unary, "test", "Read", marshaller, marshaller), + "localhost", new CallOptions()); + var upstream = new AsyncUnaryCall(Task.FromResult(new Empty()), + _ => { headerReads++; return Task.FromResult(headers); }, + _ => Status.DefaultSuccess, _ => trailers, _ => callDisposals++, new object()); + var call = lease.AsyncUnaryCall(new Empty(), context, (_, _) => upstream); + Assert.NotNull(await call.ResponseAsync); + Assert.Equal(0, headerReads); + Assert.Same(headers, await call.ResponseHeadersAsync); + Assert.Equal(1, headerReads); + Assert.Equal(Status.DefaultSuccess, call.GetStatus()); + Assert.Same(trailers, call.GetTrailers()); + call.Dispose(); + Assert.Equal(1, callDisposals); + await lease.DisposeAsync(); + Assert.Throws(() => lease.AsyncUnaryCall(new Empty(), context, (_, _) => upstream)); + } + + [Fact] + public async Task DisposalDeadline_DiscardsConnectionWithOutstandingRpc() + { + using var handler = new ControlledHandler { BlockMethod = "ExecuteSql" }; + using var httpClient = new HttpClient(handler); + var options = Options(CSharpDbGrpcMode.Native, httpClient); + var first = await CSharpDbClient.RentAsync(options, 1, Ct); + Task operation = first.ExecuteSqlAsync("SELECT 42", Ct); + await handler.Entered.Task.WaitAsync(TimeSpan.FromSeconds(10), Ct); + await first.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(12), Ct); + await Assert.ThrowsAnyAsync(() => operation.WaitAsync(TimeSpan.FromSeconds(10), Ct)); + await using var next = await CSharpDbClient.RentAsync(options, 1, Ct); + Assert.Equal(2, Count("GetInfo")); + Assert.Equal(0, Count("ExecuteSql")); + } + + [Fact] + public async Task AuthenticatedRentals_DoNotReuseConnectionsAcrossDifferentCredentials() + { + await using var secure = new GrpcSocketDaemonFixture(apiKey: "pool-test-key"); + CSharpDbClientOptions AuthOptions(string key) => new() + { + Transport = CSharpDbTransport.Grpc, Endpoint = secure.CompatibilityEndpoint.AbsoluteUri, ApiKey = key, + }; + try + { + await Assert.ThrowsAsync(() => CSharpDbClient.RentAsync(AuthOptions("wrong-key"), 1, Ct).AsTask()); + await using (var first = await CSharpDbClient.RentAsync(AuthOptions("pool-test-key"), 1, Ct)) + AssertRead(await first.ExecuteSqlAsync("SELECT 42", Ct)); + await Assert.ThrowsAsync(() => CSharpDbClient.RentAsync(AuthOptions("wrong-key"), 1, Ct).AsTask()); + await using (var next = await CSharpDbClient.RentAsync(AuthOptions("pool-test-key"), 1, Ct)) + AssertRead(await next.ExecuteSqlAsync("SELECT 42", Ct)); + Assert.Equal(3, secure.Snapshot().Where(pair => pair.Key.Method.EndsWith("/GetInfo", StringComparison.Ordinal)).Sum(pair => pair.Value)); + } + finally { await CSharpDbClient.ClearPoolAsync(secure.CompatibilityEndpoint.AbsoluteUri); } + } + + private long Count(string method) => _host.Snapshot().Where(pair => pair.Key.Method.EndsWith("/" + method, StringComparison.Ordinal)).Sum(pair => pair.Value); + private static void AssertRead(SqlExecutionResult result) + { + Assert.Null(result.Error); + Assert.Equal(42L, result.Rows![0][0]); + } + + private sealed class ControlledHandler() : DelegatingHandler(new SocketsHttpHandler()) + { + public string? FailMethod { get; set; } + public string? BlockMethod { get; init; } + public int Failures { get; private set; } + public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Resume { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + string method = request.RequestUri!.Segments[^1]; + if (method == FailMethod) { Failures++; throw new HttpRequestException("Injected RPC failure"); } + if (method == BlockMethod) { Entered.TrySetResult(); await Resume.Task.WaitAsync(cancellationToken); } + return await base.SendAsync(request, cancellationToken); + } + } +} diff --git a/tests/CSharpDB.Daemon.Tests/TransactionAdmissionSocketTests.cs b/tests/CSharpDB.Daemon.Tests/TransactionAdmissionSocketTests.cs new file mode 100644 index 00000000..af08bd13 --- /dev/null +++ b/tests/CSharpDB.Daemon.Tests/TransactionAdmissionSocketTests.cs @@ -0,0 +1,90 @@ +using System.Reflection; +using System.Runtime.ExceptionServices; +using CSharpDB.Client; +using CSharpDB.Client.Internal; +using CSharpDB.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace CSharpDB.Daemon.Tests; + +public sealed class TransactionAdmissionSocketTests +{ + [Theory] + [InlineData(CSharpDbGrpcMode.Auto, CSharpDbGrpcCallMode.Unary, false)] + [InlineData(CSharpDbGrpcMode.Auto, CSharpDbGrpcCallMode.Unary, true)] + [InlineData(CSharpDbGrpcMode.Native, CSharpDbGrpcCallMode.Unary, false)] + [InlineData(CSharpDbGrpcMode.Native, CSharpDbGrpcCallMode.Unary, true)] + [InlineData(CSharpDbGrpcMode.Native, CSharpDbGrpcCallMode.Streaming, false)] + [InlineData(CSharpDbGrpcMode.Native, CSharpDbGrpcCallMode.Streaming, true)] + public async Task SchemaOwnerCanFinishWhileAnotherConnectionWaitsForBegin( + CSharpDbGrpcMode mode, CSharpDbGrpcCallMode callMode, bool commit) + { + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + deadline.CancelAfter(TimeSpan.FromSeconds(30)); + CancellationToken ct = deadline.Token; + await using var engine = EngineTransportClient.CreatePrivateMemory("socket-admission", null, null); + ICSharpDbClient observed = DispatchProxy.Create(); + var observer = (BeginObserver)observed; + observer.Inner = engine; + await using var host = new GrpcSocketDaemonFixture(configureServices: services => services.AddSingleton(observed)); + var options = new CSharpDbClientOptions + { + Transport = CSharpDbTransport.Grpc, GrpcMode = mode, GrpcCallMode = callMode, + Endpoint = host.EndpointFor(mode).AbsoluteUri, + }; + await using var firstClient = CSharpDbClient.Create(options); + await using var secondClient = CSharpDbClient.Create(options); + using var cancelBegin = CancellationTokenSource.CreateLinkedTokenSource(ct); + Task? pending = null; + Task completion = Task.CompletedTask; + try + { + string first = (await firstClient.BeginTransactionAsync(ct)).TransactionId; + Assert.Null((await firstClient.ExecuteInTransactionAsync(first, "CREATE TABLE socket_admission (id INTEGER);", ct)).Error); + pending = secondClient.BeginTransactionAsync(cancelBegin.Token); + // Signal only after the real shared engine has returned its + // incomplete schema-admission task; request arrival alone races. + Assert.True(await observer.SecondBegin.WaitAsync(ct)); + completion = commit ? firstClient.CommitTransactionAsync(first, ct) : firstClient.RollbackTransactionAsync(first, ct); + await completion.WaitAsync(TimeSpan.FromSeconds(10), ct); + string second = (await pending.WaitAsync(ct)).TransactionId; + await secondClient.RollbackTransactionAsync(second, ct); + Assert.Equal(commit, (await firstClient.GetTableNamesAsync(ct)).Contains("socket_admission")); + } + finally + { + cancelBegin.Cancel(); + if (pending is not null) + { + try { await pending.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None); } + catch (OperationCanceledException) { } + catch (CSharpDbClientException) when (cancelBegin.IsCancellationRequested) { } + } + await completion.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None); + } + } + + public class BeginObserver : DispatchProxy + { + public ICSharpDbClient Inner { get; set; } = null!; + private int _begins; + private readonly TaskCompletionSource _secondBegin = new(TaskCreationOptions.RunContinuationsAsynchronously); + public Task SecondBegin => _secondBegin.Task; + + protected override object? Invoke(MethodInfo? method, object?[]? arguments) + { + try + { + object? result = method!.Invoke(Inner, arguments); + if (method.Name == nameof(ICSharpDbClient.BeginTransactionAsync) && Interlocked.Increment(ref _begins) == 2) + _secondBegin.TrySetResult(result is Task { IsCompleted: false }); + return result; + } + catch (TargetInvocationException error) when (error.InnerException is not null) + { + ExceptionDispatchInfo.Capture(error.InnerException).Throw(); + throw; + } + } + } +} diff --git a/tests/CSharpDB.Data.Tests/CSharpDB.Data.Tests.csproj b/tests/CSharpDB.Data.Tests/CSharpDB.Data.Tests.csproj index 699db535..a98a8b05 100644 --- a/tests/CSharpDB.Data.Tests/CSharpDB.Data.Tests.csproj +++ b/tests/CSharpDB.Data.Tests/CSharpDB.Data.Tests.csproj @@ -11,6 +11,7 @@ + diff --git a/tests/CSharpDB.Data.Tests/CommandTextClassificationTests.cs b/tests/CSharpDB.Data.Tests/CommandTextClassificationTests.cs new file mode 100644 index 00000000..d96e20fe --- /dev/null +++ b/tests/CSharpDB.Data.Tests/CommandTextClassificationTests.cs @@ -0,0 +1,233 @@ +using CSharpDB.Client; +using CSharpDB.Testing; + +namespace CSharpDB.Data.Tests; + +// Exercise observable command reuse, rather than inspecting the classification cache. +[Collection("ConnectionPoolState")] +public sealed class CommandTextClassificationTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ReassignedSql_RebindsCurrentParametersAndInvalidatesPreparedTemplate(bool prepare) + { + await using var connection = new CSharpDbConnection("Data Source=:memory:;Pooling=false"); + await connection.OpenAsync(Ct); + await ExerciseParameterTransitionsAsync(connection, prepare); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task QuotedAndCommentMarkers_KeepTokenizerBindingSemantics(bool prepare) + { + await using var connection = new CSharpDbConnection("Data Source=:memory:;Pooling=false"); + await connection.OpenAsync(Ct); + await ExerciseQuotedMarkersAsync(connection, prepare); + } + + [Fact] + public async Task ReusedCommand_RecognizesNormalizedStartBeginCommitAndRollback() + { + await using var connection = new CSharpDbConnection("Data Source=:memory:;Pooling=false"); + await connection.OpenAsync(Ct); + await ExerciseTransactionTransitionsAsync(connection); + } + + [Theory] + [InlineData("COMMIT; SELECT 1")] + [InlineData("COMMIT ; ;")] + [InlineData("COMMIT /* comment */")] + [InlineData("ROLLBACK; SELECT 1")] + [InlineData("BEGIN")] + [InlineData("BEGIN TRANSACTION")] + [InlineData("BEGIN TRANSACTION; SELECT 1")] + [InlineData("START TRANSACTION; SELECT 1")] + public async Task TransactionLookalikes_AreDispatchedAsSqlAndDoNotCommitPendingWrites(string sql) + { + await using var connection = new CSharpDbConnection("Data Source=:memory:;Pooling=false"); + await connection.OpenAsync(Ct); + using var command = (CSharpDbCommand)connection.CreateCommand(); + command.CommandText = "CREATE TABLE classification_pending (id INTEGER PRIMARY KEY)"; + await command.ExecuteNonQueryAsync(Ct); + command.CommandText = "BEGIN TRANSACTION"; + Assert.Null(await command.ExecuteScalarAsync(Ct)); + command.CommandText = "INSERT INTO classification_pending VALUES (1)"; + Assert.Equal(1, await command.ExecuteNonQueryAsync(Ct)); + + // These strings do not equal one of the four transaction commands after + // Trim().TrimEnd(';').Trim(). In particular, spaced semicolons and a + // transaction prefix in a script must not be broadened into COMMIT. + command.CommandText = sql; + await Assert.ThrowsAsync(() => command.ExecuteScalarAsync(Ct)); + + command.CommandText = " \t ROLLBACK;;; \r\n"; + Assert.Null(await command.ExecuteScalarAsync(Ct)); + command.CommandText = "SELECT COUNT(*) FROM classification_pending"; + Assert.Equal(0L, Convert.ToInt64(await command.ExecuteScalarAsync(Ct))); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" \t\r\n ")] + [InlineData(";;")] + public async Task NullAndEmptyAssignments_MatchFreshCommandsAfterPreparedAndTransactionText(string? sql) + { + await using var connection = new CSharpDbConnection("Data Source=:memory:;Pooling=false"); + await connection.OpenAsync(Ct); + using var command = (CSharpDbCommand)connection.CreateCommand(); + command.CommandText = "SELECT @value"; + command.Parameters.AddWithValue("@value", 7); + command.Prepare(); + Assert.Equal(7L, Convert.ToInt64(await command.ExecuteScalarAsync(Ct))); + command.CommandText = "BEGIN TRANSACTION"; + Assert.Null(await command.ExecuteScalarAsync(Ct)); + command.CommandText = "ROLLBACK"; + Assert.Null(await command.ExecuteScalarAsync(Ct)); + + using var fresh = (CSharpDbCommand)connection.CreateCommand(); + fresh.CommandText = sql; + command.CommandText = sql; + Assert.Equal(sql ?? string.Empty, command.CommandText); + Exception? expected = await Record.ExceptionAsync(() => fresh.ExecuteScalarAsync(Ct)); + Exception? actual = await Record.ExceptionAsync(() => command.ExecuteScalarAsync(Ct)); + Assert.NotNull(expected); + Assert.NotNull(actual); + Assert.Equal(expected.GetType(), actual.GetType()); + Assert.Equal(expected.Message, actual.Message); + + command.CommandText = "SELECT 9"; + Assert.Equal(9L, Convert.ToInt64(await command.ExecuteScalarAsync(Ct))); + } + + [Theory(Timeout = 45000)] + [InlineData(CSharpDbGrpcMode.Auto, CSharpDbGrpcCallMode.Unary)] + [InlineData(CSharpDbGrpcMode.Native, CSharpDbGrpcCallMode.Unary)] + [InlineData(CSharpDbGrpcMode.Native, CSharpDbGrpcCallMode.Streaming)] + public async Task RemoteScalarAndQueryPaths_ReclassifyAndRebindTheSameCommand( + CSharpDbGrpcMode mode, CSharpDbGrpcCallMode calls) + { + var ct = TestContext.Current.CancellationToken; + await using var host = new GrpcSocketDaemonFixture(); + string endpoint = host.EndpointFor(mode).AbsoluteUri; + try + { + await using var connection = new CSharpDbConnection( + $"Transport=Grpc;Endpoint={endpoint};Grpc Mode={mode};Grpc Call Mode={calls};Pooling=true;Max Pool Size=1"); + await connection.OpenAsync(ct); + await ExerciseParameterTransitionsAsync(connection, prepare: true); + await ExerciseQuotedMarkersAsync(connection, prepare: true); + await ExerciseTransactionTransitionsAsync(connection); + } + finally + { + await CSharpDbClient.ClearPoolAsync(endpoint); + } + } + + private static async Task ExerciseParameterTransitionsAsync(CSharpDbConnection connection, bool prepare) + { + using var command = (CSharpDbCommand)connection.CreateCommand(); + command.CommandText = "SELECT 11"; + if (prepare) command.Prepare(); + Assert.Equal(11L, Convert.ToInt64(await command.ExecuteScalarAsync(Ct))); + + command.CommandText = "SELECT @value"; + var value = command.Parameters.AddWithValue("@value", 7); + if (prepare) command.Prepare(); + Assert.Equal(7L, Convert.ToInt64(await command.ExecuteScalarAsync(Ct))); + value.Value = 19; + Assert.Equal(19L, Convert.ToInt64(await command.ExecuteScalarAsync(Ct))); + + value.ParameterName = "@renamed"; + await AssertMissingParameterAsync(command, "value"); + // Do not prepare again: reassignment itself must invalidate the template. + command.CommandText = "SELECT @renamed + 1"; + Assert.Equal(20L, Convert.ToInt64(await command.ExecuteScalarAsync(Ct))); + command.Parameters.Clear(); + await AssertMissingParameterAsync(command, "renamed"); + value = command.Parameters.AddWithValue("renamed", 23); + Assert.Equal(24L, Convert.ToInt64(await command.ExecuteScalarAsync(Ct))); + + // A marker-free assignment must stop rebinding the previous template, + // even while its old parameter has an unusable value. + value.Value = new ExplodingValue(); + command.CommandText = "SELECT 31"; + Assert.Equal(31L, Convert.ToInt64(await command.ExecuteScalarAsync(Ct))); + command.CommandText = "SELECT @renamed"; + value.Value = 41; + Assert.Equal(41L, Convert.ToInt64(await command.ExecuteScalarAsync(Ct))); + command.Parameters.Clear(); + await AssertMissingParameterAsync(command, "renamed"); + } + + private static async Task ExerciseQuotedMarkersAsync(CSharpDbConnection connection, bool prepare) + { + using var command = (CSharpDbCommand)connection.CreateCommand(); + command.CommandText = "SELECT 'COMMIT; @literal' AS \"@alias\" /* @block */ -- @line\n"; + if (prepare) command.Prepare(); + Assert.Equal("COMMIT; @literal", await command.ExecuteScalarAsync(Ct)); + command.Parameters.AddWithValue("@unused", new ExplodingValue()); + Assert.Equal("COMMIT; @literal", await command.ExecuteScalarAsync(Ct)); + + command.CommandText = "SELECT @actual AS \"@alias\" /* @missing */ -- @missing\n"; + if (prepare) command.Prepare(); + await AssertMissingParameterAsync(command, "actual"); + var actual = command.Parameters.AddWithValue("@actual", "first"); + Assert.Equal("first", await command.ExecuteScalarAsync(Ct)); + actual.Value = "' OR 1 = 1 -- @missing"; + Assert.Equal("' OR 1 = 1 -- @missing", await command.ExecuteScalarAsync(Ct)); + command.Parameters.Clear(); + + // Reassigning identical text must also discard a previously prepared + // template without treating quoted/comment markers as parameters. + command.CommandText = "SELECT '@literal' /* @missing */"; + if (prepare) command.Prepare(); + command.CommandText = command.CommandText; + Assert.Equal("@literal", await command.ExecuteScalarAsync(Ct)); + } + + private static async Task ExerciseTransactionTransitionsAsync(CSharpDbConnection connection) + { + using var command = (CSharpDbCommand)connection.CreateCommand(); + command.CommandText = "CREATE TABLE classification_tx (id INTEGER PRIMARY KEY)"; + await command.ExecuteNonQueryAsync(Ct); + command.CommandText = "SELECT 1"; + Assert.Equal(1L, Convert.ToInt64(await command.ExecuteScalarAsync(Ct))); + + command.CommandText = " \tStArT TrAnSaCtIoN;;; \r\n"; + Assert.Null(await command.ExecuteScalarAsync(Ct)); + command.CommandText = "INSERT INTO classification_tx VALUES (1)"; + Assert.Equal(1, await command.ExecuteNonQueryAsync(Ct)); + command.CommandText = "\r\n cOmMiT;;; \t"; + await using (var reader = await command.ExecuteReaderAsync(Ct)) + Assert.False(await reader.ReadAsync(Ct)); + command.CommandText = "SELECT COUNT(*) FROM classification_tx"; + Assert.Equal(1L, Convert.ToInt64(await command.ExecuteScalarAsync(Ct))); + + command.CommandText = " \t bEgIn TrAnSaCtIoN; \r\n"; + Assert.Equal(0, await command.ExecuteNonQueryAsync(Ct)); + command.CommandText = "INSERT INTO classification_tx VALUES (2)"; + Assert.Equal(1, await command.ExecuteNonQueryAsync(Ct)); + command.CommandText = " rOlLbAcK;;; \t"; + Assert.Null(await command.ExecuteScalarAsync(Ct)); + command.CommandText = "SELECT COUNT(*) FROM classification_tx"; + Assert.Equal(1L, Convert.ToInt64(await command.ExecuteScalarAsync(Ct))); + } + + private static async Task AssertMissingParameterAsync(CSharpDbCommand command, string name) + { + InvalidOperationException error = await Assert.ThrowsAsync( + () => command.ExecuteScalarAsync(Ct)); + Assert.Equal($"Parameter '@{name}' was not supplied.", error.Message); + } + + private sealed class ExplodingValue + { + public override string ToString() => throw new InvalidOperationException("Unused parameter was evaluated."); + } +} diff --git a/tests/CSharpDB.Data.Tests/CompactScalarNetworkTests.cs b/tests/CSharpDB.Data.Tests/CompactScalarNetworkTests.cs new file mode 100644 index 00000000..d457b338 --- /dev/null +++ b/tests/CSharpDB.Data.Tests/CompactScalarNetworkTests.cs @@ -0,0 +1,175 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Reflection; +using System.Runtime.ExceptionServices; +using CSharpDB.Client; +using CSharpDB.Client.Grpc; +using CSharpDB.Client.Models; +using CSharpDB.Testing; +using Grpc.Core; +using Grpc.Core.Interceptors; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CSharpDB.Data.Tests; + +[Collection("ConnectionPoolState")] +public sealed class CompactScalarNetworkTests +{ + [Theory(Timeout = 45000)] + [InlineData(CSharpDbGrpcMode.Auto, CSharpDbGrpcCallMode.Unary)] + [InlineData(CSharpDbGrpcMode.Native, CSharpDbGrpcCallMode.Unary)] + [InlineData(CSharpDbGrpcMode.Native, CSharpDbGrpcCallMode.Streaming)] + public async Task ScalarsAndReaders_PreserveSignedLimitsAndDeclaredTypes( + CSharpDbGrpcMode mode, CSharpDbGrpcCallMode calls) + { + var ct = TestContext.Current.CancellationToken; + var capture = new CompactCapture(); + await using var host = Host(capture); + string endpoint = host.EndpointFor(mode).AbsoluteUri; + try + { + await using var connection = new CSharpDbConnection(ConnectionString(endpoint, mode, calls)); + await connection.OpenAsync(ct); + using var command = connection.CreateCommand(); + foreach (long value in new[] { 0L, -1L, 1L, long.MinValue, long.MaxValue, 2147483648L }) + { + command.CommandText = $"SELECT CAST({value.ToString(CultureInfo.InvariantCulture)} AS BIGINT) AS value"; + Assert.Equal(value, Assert.IsType(await command.ExecuteScalarAsync(ct))); + using var reader = await command.ExecuteReaderAsync(ct); + Assert.Equal(typeof(long), reader.GetFieldType(0)); + Assert.True(await reader.ReadAsync(ct)); + Assert.Equal(value, reader.GetInt64(0)); + Assert.False(await reader.ReadAsync(ct)); + } + foreach (int value in new[] { int.MinValue, 0, int.MaxValue }) + { + command.CommandText = $"SELECT CAST({value.ToString(CultureInfo.InvariantCulture)} AS INTEGER) AS value"; + Assert.Equal(value, Assert.IsType(await command.ExecuteScalarAsync(ct))); + using var reader = await command.ExecuteReaderAsync(ct); + Assert.Equal(typeof(int), reader.GetFieldType(0)); + Assert.True(await reader.ReadAsync(ct)); + Assert.Equal(value, reader.GetInt32(0)); + } + var integerReplies = capture.Results.ToArray(); + Assert.Equal(18, integerReplies.Length); + Assert.All(integerReplies, result => Assert.Equal(calls == CSharpDbGrpcCallMode.Streaming, result.HasScalarInt64)); + if (calls == CSharpDbGrpcCallMode.Streaming) + Assert.All(integerReplies, result => Assert.Null(result.Rows)); + + (string Sql, object Expected)[] fallback = + [ + ("SELECT CAST(NULL AS INTEGER) AS value", DBNull.Value), + ("SELECT CAST(1 AS BOOLEAN) AS value", true), + ("SELECT CAST(0 AS BOOLEAN) AS value", false), + ("SELECT CAST(12.34 AS DECIMAL(10,2)) AS value", 12.34m), + ]; + foreach (var test in fallback) + { + command.CommandText = test.Sql; + object? actual = await command.ExecuteScalarAsync(ct); + Assert.Equal(test.Expected.GetType(), actual?.GetType()); + Assert.Equal(test.Expected, actual); + Assert.False(capture.Results.Last().HasScalarInt64); + Assert.NotNull(capture.Results.Last().Rows); + } + } + finally { await CSharpDbClient.ClearPoolAsync(endpoint); } + } + + [Theory(Timeout = 30000)] + [InlineData(2147483648L, false)] + [InlineData(0L, true)] + [InlineData(1L, true)] + public async Task CompactCarrier_PreservesCheckedIntegerAndBooleanConversion(long value, bool boolean) + { + var ct = TestContext.Current.CancellationToken; + var capture = new CompactCapture(); + var response = new SqlExecutionResult + { + IsQuery = true, ColumnNames = ["value"], ColumnTypes = [boolean ? "BOOLEAN" : "INTEGER"], + ColumnNullability = [false], Rows = [[value]], + Columns = [new ColumnDefinition + { + Name = "value", Type = Client.Models.DbType.Integer, Nullable = false, + DeclaredType = new() { Kind = boolean ? SqlTypeKind.Boolean : SqlTypeKind.Integer }, + }], + }; + await using var host = Host(capture, services => + { + var original = services.Last(service => service.ServiceType == typeof(ICSharpDbClient)); + services.Replace(ServiceDescriptor.Singleton(provider => + { + var proxy = DispatchProxy.Create(); + var state = (ControlledBackend)(object)proxy; + state.Inner = (ICSharpDbClient)original.ImplementationFactory!(provider); + state.Result = response; + return proxy; + })); + }); + string endpoint = host.NativeEndpoint.AbsoluteUri; + try + { + await using var connection = new CSharpDbConnection(ConnectionString(endpoint, CSharpDbGrpcMode.Native, CSharpDbGrpcCallMode.Streaming)); + await connection.OpenAsync(ct); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT controlled_carrier"; + if (boolean) + Assert.Equal(value != 0, Assert.IsType(await command.ExecuteScalarAsync(ct))); + else + await Assert.ThrowsAsync(() => command.ExecuteScalarAsync(ct)); + Assert.True(Assert.Single(capture.Results).HasScalarInt64); + } + finally { await CSharpDbClient.ClearPoolAsync(endpoint); } + } + + private static string ConnectionString(string endpoint, CSharpDbGrpcMode mode, CSharpDbGrpcCallMode calls) + => $"Transport=Grpc;Endpoint={endpoint};Grpc Mode={mode};Grpc Call Mode={calls};Pooling=true;Max Pool Size=1"; + + private static GrpcSocketDaemonFixture Host(CompactCapture capture, Action? configure = null) + => new(configureServices: services => + { + services.AddSingleton(capture); + services.AddGrpc(options => options.Interceptors.Add()); + configure?.Invoke(services); + }); + + public class ControlledBackend : DispatchProxy + { + public ICSharpDbClient Inner = null!; + public SqlExecutionResult Result = null!; + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + if (targetMethod!.Name == nameof(ICSharpDbClient.ExecuteSqlAsync)) return Task.FromResult(Result); + try { return targetMethod.Invoke(Inner, args); } + catch (TargetInvocationException error) when (error.InnerException is not null) + { ExceptionDispatchInfo.Capture(error.InnerException).Throw(); throw; } + } + } + + public sealed class CompactCapture : Interceptor + { + public ConcurrentQueue Results { get; } = new(); + public override async Task UnaryServerHandler(TRequest request, + ServerCallContext context, UnaryServerMethod continuation) + { + TResponse response = await continuation(request, context); + if (response is SqlExecutionResultMessage result) Results.Enqueue(result.Clone()); + return response; + } + public override Task DuplexStreamingServerHandler(IAsyncStreamReader reader, + IServerStreamWriter writer, ServerCallContext context, + DuplexStreamingServerMethod continuation) + => continuation(reader, new Writer(writer, this), context); + + private sealed class Writer(IServerStreamWriter inner, CompactCapture owner) : IServerStreamWriter + { + public WriteOptions? WriteOptions { get => inner.WriteOptions; set => inner.WriteOptions = value; } + public Task WriteAsync(T response) + { + if (response is CommandSessionResponse { Result: not null } message) owner.Results.Enqueue(message.Result.Clone()); + return inner.WriteAsync(response); + } + } + } +} diff --git a/tests/CSharpDB.Data.Tests/ConcurrentNetworkTransactionTests.cs b/tests/CSharpDB.Data.Tests/ConcurrentNetworkTransactionTests.cs new file mode 100644 index 00000000..eecfcdb6 --- /dev/null +++ b/tests/CSharpDB.Data.Tests/ConcurrentNetworkTransactionTests.cs @@ -0,0 +1,155 @@ +using System.Data.Common; +using CSharpDB.Client; +using CSharpDB.Testing; + +namespace CSharpDB.Data.Tests; + +[Collection("ConnectionPoolState")] +public sealed class ConcurrentNetworkTransactionTests : IDisposable +{ + private readonly CancellationTokenSource _deadline = + CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + private CancellationToken Ct => _deadline.Token; + public ConcurrentNetworkTransactionTests() => _deadline.CancelAfter(TimeSpan.FromSeconds(60)); + public void Dispose() => _deadline.Dispose(); + + [Theory] + [InlineData(CSharpDbGrpcMode.Auto, false)] + [InlineData(CSharpDbGrpcMode.Auto, true)] + [InlineData(CSharpDbGrpcMode.Native, false)] + [InlineData(CSharpDbGrpcMode.Native, true)] + public async Task ConflictingTransactions_ReportConflict_AndNeverReplayTheRejectedUpdate( + CSharpDbGrpcMode mode, bool ado) + { + await using var host = new GrpcSocketDaemonFixture(); + string endpoint = host.EndpointFor(mode).AbsoluteUri; + var options = new CSharpDbClientOptions + { Transport = CSharpDbTransport.Grpc, GrpcMode = mode, Endpoint = endpoint }; + await using var observer = host.CreateClient(mode); + Assert.Null((await observer.ExecuteSqlAsync( + "CREATE TABLE rejected_tx (id INTEGER PRIMARY KEY, value INTEGER); INSERT INTO rejected_tx VALUES (1, 0);", Ct)).Error); + try + { + if (ado) + { + string cs = $"Endpoint={endpoint};Transport=Grpc;Grpc Mode={mode};Pooling=true;Max Pool Size=2"; + await using var first = new CSharpDbConnection(cs); + await using var second = new CSharpDbConnection(cs); + await first.OpenAsync(Ct); + await second.OpenAsync(Ct); + await using var firstTx = await first.BeginTransactionAsync(Ct); + await using var secondTx = await second.BeginTransactionAsync(Ct); + using var command = first.CreateCommand(); + command.Transaction = firstTx; + command.CommandText = "UPDATE rejected_tx SET value = 1 WHERE id = 1;"; + await command.ExecuteNonQueryAsync(Ct); + using var other = second.CreateCommand(); + other.Transaction = secondTx; + other.CommandText = "UPDATE rejected_tx SET value = 2 WHERE id = 1;"; + await other.ExecuteNonQueryAsync(Ct); + await firstTx.CommitAsync(Ct); + var error = await Assert.ThrowsAsync(() => secondTx.CommitAsync(Ct)); + Assert.Equal(CSharpDB.Primitives.ErrorCode.TransactionConflict, error.ErrorCode); + } + else + { + await using var first = await CSharpDbClient.RentAsync(options, 2, Ct); + await using var second = await CSharpDbClient.RentAsync(options, 2, Ct); + string firstTx = (await first.BeginTransactionAsync(Ct)).TransactionId; + string secondTx = (await second.BeginTransactionAsync(Ct)).TransactionId; + await first.ExecuteInTransactionAsync(firstTx, "UPDATE rejected_tx SET value = 1 WHERE id = 1;", Ct); + await second.ExecuteInTransactionAsync(secondTx, "UPDATE rejected_tx SET value = 2 WHERE id = 1;", Ct); + await first.CommitTransactionAsync(firstTx, Ct); + var error = await Assert.ThrowsAsync(() => second.CommitTransactionAsync(secondTx, Ct)); + Assert.Equal(CSharpDB.Primitives.ErrorCode.TransactionConflict, error.Code); + } + await using var recovered = await CSharpDbClient.RentAsync(options, 2, Ct); + Assert.Equal(1L, (await recovered.ExecuteSqlAsync("SELECT value FROM rejected_tx WHERE id = 1;", Ct)).Rows![0][0]); + Assert.Null((await recovered.ExecuteSqlAsync("INSERT INTO rejected_tx VALUES (2, 3);", Ct)).Error); + Assert.Equal(2L, (await observer.ExecuteSqlAsync("SELECT COUNT(*) FROM rejected_tx;", Ct)).Rows![0][0]); + } + finally { await CSharpDbClient.ClearPoolAsync(endpoint); } + } + + [Theory] + [InlineData(CSharpDbGrpcMode.Auto, false)] + [InlineData(CSharpDbGrpcMode.Auto, true)] + [InlineData(CSharpDbGrpcMode.Native, false)] + [InlineData(CSharpDbGrpcMode.Native, true)] + public async Task EightPooledTransactions_CommitRollbackAndAbandon_WithoutClosingSharedWal( + CSharpDbGrpcMode mode, bool ado) + { + await using var host = new GrpcSocketDaemonFixture(); + string endpoint = host.EndpointFor(mode).AbsoluteUri; + var options = new CSharpDbClientOptions + { Transport = CSharpDbTransport.Grpc, GrpcMode = mode, Endpoint = endpoint }; + string cs = $"Endpoint={endpoint};Transport=Grpc;Grpc Mode={mode};Pooling=true;Max Pool Size=8"; + await using var observer = host.CreateClient(mode); + Assert.Null((await observer.ExecuteSqlAsync("CREATE TABLE concurrent_tx (id INTEGER PRIMARY KEY);", Ct)).Error); + try + { + for (int round = 0; round < 3; round++) + { + TestContext.Current.TestOutputHelper?.WriteLine($"{mode}, ADO={ado}: starting round {round}"); + var clients = new List(); + var ids = new List(); + var connections = new List(); + var transactions = new List(); + try + { + for (int i = 0; i < 8; i++) + { + string sql = $"INSERT INTO concurrent_tx VALUES ({round * 8 + i});"; + if (ado) + { + var connection = new CSharpDbConnection(cs); + connections.Add(connection); + await connection.OpenAsync(Ct); + DbTransaction transaction = await connection.BeginTransactionAsync(Ct); + transactions.Add(transaction); + using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = sql; + Assert.Equal(1, await command.ExecuteNonQueryAsync(Ct)); + } + else + { + ICSharpDbClient client = await CSharpDbClient.RentAsync(options, 8, Ct); + clients.Add(client); + string id = (await client.BeginTransactionAsync(Ct)).TransactionId; + ids.Add(id); + Assert.Null((await client.ExecuteInTransactionAsync(id, sql, Ct)).Error); + } + } + Assert.Equal(round * 4L, + (await observer.ExecuteSqlAsync("SELECT COUNT(*) FROM concurrent_tx;", Ct)).Rows![0][0]); + TestContext.Current.TestOutputHelper?.WriteLine($"{mode}, ADO={ado}: all eight transactions open; finishing round {round}"); + + // Finish concurrently while the remaining sessions still pin + // their snapshots. Odd sessions roll back; the last is left + // unfinished and must be reset when its rental is returned. + await Task.WhenAll(Enumerable.Range(0, 7).Select(i => ado + ? (i % 2 == 0 ? transactions[i].CommitAsync(Ct) : transactions[i].RollbackAsync(Ct)) + : (i % 2 == 0 ? clients[i].CommitTransactionAsync(ids[i], Ct) + : clients[i].RollbackTransactionAsync(ids[i], Ct)))); + } + finally + { + foreach (DbTransaction transaction in transactions) + await transaction.DisposeAsync(); + foreach (CSharpDbConnection connection in connections) + await connection.DisposeAsync(); + foreach (ICSharpDbClient client in clients) + await client.DisposeAsync(); + } + + Assert.Equal((round + 1) * 4L, + (await observer.ExecuteSqlAsync("SELECT COUNT(*) FROM concurrent_tx;", Ct)).Rows![0][0]); + TestContext.Current.TestOutputHelper?.WriteLine($"{mode}, ADO={ado}: round {round} verified"); + } + Assert.Equal(Enumerable.Range(0, 24).Where(i => i % 2 == 0).Select(i => (long)i), + (await observer.ExecuteSqlAsync("SELECT id FROM concurrent_tx ORDER BY id;", Ct)).Rows!.Select(row => (long)row[0]!)); + } + finally { await CSharpDbClient.ClearPoolAsync(endpoint); } + } +} diff --git a/tests/CSharpDB.Data.Tests/ConcurrentStreamingPoolTests.cs b/tests/CSharpDB.Data.Tests/ConcurrentStreamingPoolTests.cs new file mode 100644 index 00000000..73640dcc --- /dev/null +++ b/tests/CSharpDB.Data.Tests/ConcurrentStreamingPoolTests.cs @@ -0,0 +1,357 @@ +using System.Data; +using System.Data.Common; +using System.Reflection; +using System.Runtime.ExceptionServices; +using CSharpDB.Client; +using CSharpDB.Client.Models; +using CSharpDB.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CSharpDB.Data.Tests; + +[Collection("ConnectionPoolState")] +public sealed class ConcurrentStreamingPoolTests +{ + private const int Capacity = 8; + + [Fact(Timeout = 45000)] + public async Task EightMixedRentals_ShareOneCapacityLimit_AndReuseEachOthersSessions() + { + var ct = TestContext.Current.CancellationToken; + await using var host = new GrpcSocketDaemonFixture(); + await SeedAsync(host, ct); + Rental[] rentals = []; + try + { + rentals = await OpenEightAsync(host, reverseApis: false, ct); + Assert.Equal(Capacity, SessionCount(host)); + await Task.WhenAll(rentals.Select(async (rental, id) => + { + for (int read = 0; read < 16; read++) + Assert.Equal(0L, await rental.ScalarAsync($"SELECT value FROM stream_concurrent WHERE id = {id}", ct)); + })); + + // Both APIs must wait on the same eight slots. Cancellation of a + // capacity waiter must neither create a stream nor consume a slot. + using (var cancellation = CancellationTokenSource.CreateLinkedTokenSource(ct)) + { + Task sdkWaiter = Rental.OpenAsync(host, ado: false, cancellation.Token); + Assert.False(sdkWaiter.IsCompleted); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => sdkWaiter); + } + await using (var canceledAdo = new CSharpDbConnection(ConnectionString(host))) + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(ct); + Task open = canceledAdo.OpenAsync(cancellation.Token); + Assert.False(open.IsCompleted); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => open); + Assert.Equal(ConnectionState.Closed, canceledAdo.State); + } + Assert.Equal(Capacity, SessionCount(host)); + + Task adoWaitingForSdk = Rental.OpenAsync(host, ado: true, ct); + Assert.False(adoWaitingForSdk.IsCompleted); + await rentals[0].DisposeAsync(); // The only available slot and owner are from the SDK. + await using var ado = await adoWaitingForSdk.WaitAsync(TimeSpan.FromSeconds(10), ct); + Assert.Equal(0L, await ado.ScalarAsync("SELECT value FROM stream_concurrent WHERE id = 0", ct)); + + Task sdkWaitingForAdo = Rental.OpenAsync(host, ado: false, ct); + Assert.False(sdkWaitingForAdo.IsCompleted); + await rentals[1].DisposeAsync(); + await using var sdk = await sdkWaitingForAdo.WaitAsync(TimeSpan.FromSeconds(10), ct); + Assert.Equal(0L, await sdk.ScalarAsync("SELECT value FROM stream_concurrent WHERE id = 1", ct)); + Assert.Equal(Capacity, SessionCount(host)); + Assert.All(host.Snapshot(), request => Assert.Equal("HTTP/2", request.Key.Protocol)); + } + finally + { + await DisposeAsync(rentals); + await CSharpDbClient.ClearPoolAsync(host.NativeEndpoint.AbsoluteUri); + } + } + + [Fact(Timeout = 45000)] + public async Task EightMixedTransactions_IsolateWrites_AndResetAbandonedStateBeforeReuse() + { + var ct = TestContext.Current.CancellationToken; + await using var host = new GrpcSocketDaemonFixture(); + await SeedAsync(host, ct); + await using var observer = host.CreateClient(CSharpDbGrpcMode.Native); + // Existing rows in distinct tables occupy independent write pages. The + // engine detects page conflicts, so distinct keys on one small table + // would not be a valid assumption for concurrent successful commits. + for (int id = 0; id < Capacity; id++) + Assert.Null((await observer.ExecuteSqlAsync( + $"CREATE TABLE stream_tx_{id} (id INTEGER PRIMARY KEY, value INTEGER); INSERT INTO stream_tx_{id} VALUES (0, 0)", ct)).Error); + Rental[] rentals = []; + try + { + rentals = await OpenEightAsync(host, reverseApis: false, ct); + await Task.WhenAll(rentals.Select(rental => rental.BeginAsync(ct))); + await Task.WhenAll(rentals.Select((rental, id) => + rental.ExecuteAsync($"UPDATE stream_tx_{id} SET value = {100 + id} WHERE id = 0", ct))); + + // Every snapshot contains exactly its own write, while ordinary + // readers still see the committed zeros. No commit ordering is + // needed to establish this isolation boundary. + await Task.WhenAll(rentals.Select(async (rental, id) => + { + Assert.Equal(100L + id, await rental.ScalarAsync($"SELECT value FROM stream_tx_{id} WHERE id = 0", ct)); + Assert.Equal(0L, await rental.ScalarAsync($"SELECT value FROM stream_tx_{(id + 1) % Capacity} WHERE id = 0", ct)); + })); + for (int id = 0; id < Capacity; id++) + Assert.Equal(0L, (await observer.ExecuteSqlAsync($"SELECT value FROM stream_tx_{id} WHERE id = 0", ct)).Rows![0][0]); + + // Both APIs commit, explicitly roll back, and abandon a transaction. + // Finish the other sessions while the abandoned ones pin snapshots. + await Task.WhenAll(rentals.Select((rental, id) => (id % 3) switch + { + 0 => rental.CommitAsync(ct), + 1 => rental.RollbackAsync(ct), + _ => Task.CompletedTask, + })); + await DisposeAsync(rentals); + + rentals = await OpenEightAsync(host, reverseApis: true, ct); + await Task.WhenAll(rentals.Select(async rental => + { + for (int id = 0; id < Capacity; id++) + Assert.Equal(id % 3 == 0 ? 100L + id : 0L, + await rental.ScalarAsync($"SELECT value FROM stream_tx_{id} WHERE id = 0", ct)); + // A new transaction must be possible on every returned owner. + await rental.BeginAsync(ct); + await rental.RollbackAsync(ct); + })); + Assert.Equal(Capacity, SessionCount(host)); + } + finally + { + await DisposeAsync(rentals); + await CSharpDbClient.ClearPoolAsync(host.NativeEndpoint.AbsoluteUri); + } + } + + [Fact(Timeout = 45000)] + public async Task CancelingOneActiveStream_DoesNotReplayOrInterruptTheOtherSeven() + { + var ct = TestContext.Current.CancellationToken; + var gate = new CancellationGate(); + await using var host = new GrpcSocketDaemonFixture(configureServices: services => Decorate(services, gate)); + await SeedAsync(host, ct); + Rental[] rentals = []; + try + { + rentals = await OpenEightAsync(host, reverseApis: false, ct); + await rentals[0].BeginAsync(ct); + await rentals[0].ExecuteAsync("UPDATE stream_concurrent SET value = 99 WHERE id = 0", ct); + + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(ct); + Task active = rentals[0].Sdk!.ExecuteSqlAsync(CancellationGate.BlockedSql, cancellation.Token); + await gate.Entered.Task.WaitAsync(TimeSpan.FromSeconds(10), ct); + await Task.WhenAll(rentals.Skip(1).Select(async (rental, index) => + { + for (int read = 0; read < 16; read++) + Assert.Equal(0L, await rental.ScalarAsync($"SELECT value FROM stream_concurrent WHERE id = {index + 1}", ct)); + })); + Assert.False(active.IsCompleted); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => active.WaitAsync(TimeSpan.FromSeconds(10), ct)); + await gate.RolledBack.Task.WaitAsync(TimeSpan.FromSeconds(10), ct); + await rentals[0].DisposeAsync(); + + // The seven healthy owners are still rented, so only the canceled + // owner can be replaced. Its uncommitted value must have vanished. + await using var replacement = await Rental.OpenAsync(host, ado: true, ct); + Assert.Equal(0L, await replacement.ScalarAsync("SELECT value FROM stream_concurrent WHERE id = 0", ct)); + await Task.WhenAll(rentals.Skip(1).Select(async rental => + Assert.Equal(0L, await rental.ScalarAsync("SELECT SUM(value) FROM stream_concurrent", ct)))); + Assert.Equal(1, Volatile.Read(ref gate.BlockedCalls)); + Assert.Equal(Capacity + 1, SessionCount(host)); + } + finally + { + gate.Release.TrySetResult(); + await DisposeAsync(rentals); + await CSharpDbClient.ClearPoolAsync(host.NativeEndpoint.AbsoluteUri); + } + } + + private static CSharpDbClientOptions Options(GrpcSocketDaemonFixture host) => new() + { + Transport = CSharpDbTransport.Grpc, + Endpoint = host.NativeEndpoint.AbsoluteUri, + GrpcMode = CSharpDbGrpcMode.Native, + GrpcCallMode = CSharpDbGrpcCallMode.Streaming, + }; + + private static string ConnectionString(GrpcSocketDaemonFixture host) + => $"Transport=Grpc;Endpoint={host.NativeEndpoint};Grpc Mode=Native;Grpc Call Mode=Streaming;Pooling=true;Max Pool Size={Capacity}"; + + private static long SessionCount(GrpcSocketDaemonFixture host) + => host.Snapshot().Where(p => p.Key.Method.EndsWith("/CommandSession", StringComparison.Ordinal)).Sum(p => p.Value); + + private static async Task SeedAsync(GrpcSocketDaemonFixture host, CancellationToken ct) + { + await using var observer = host.CreateClient(CSharpDbGrpcMode.Native); + string rows = string.Join(", ", Enumerable.Range(0, Capacity).Select(id => $"({id}, 0)")); + Assert.Null((await observer.ExecuteSqlAsync( + $"CREATE TABLE stream_concurrent (id INTEGER PRIMARY KEY, value INTEGER); INSERT INTO stream_concurrent VALUES {rows}", ct)).Error); + } + + private static async Task OpenEightAsync(GrpcSocketDaemonFixture host, bool reverseApis, CancellationToken ct) + { + Task[] tasks = Enumerable.Range(0, Capacity) + .Select(id => Rental.OpenAsync(host, ado: (id % 2 == 1) != reverseApis, ct)).ToArray(); + try { return await Task.WhenAll(tasks); } + catch + { + await DisposeAsync(tasks.Where(task => task.IsCompletedSuccessfully).Select(task => task.Result)); + throw; + } + } + + private static Task DisposeAsync(IEnumerable rentals) + => Task.WhenAll(rentals.Select(rental => rental.DisposeAsync().AsTask())); + + private sealed class Rental : IAsyncDisposable + { + public ICSharpDbClient? Sdk { get; private init; } + private CSharpDbConnection? Connection { get; init; } + private string? _transactionId; + private DbTransaction? _transaction; + private int _disposed; + + public static async Task OpenAsync(GrpcSocketDaemonFixture host, bool ado, CancellationToken ct) + { + if (!ado) return new Rental { Sdk = await CSharpDbClient.RentAsync(Options(host), Capacity, ct) }; + var connection = new CSharpDbConnection(ConnectionString(host)); + try { await connection.OpenAsync(ct); return new Rental { Connection = connection }; } + catch { await connection.DisposeAsync(); throw; } + } + + public async Task BeginAsync(CancellationToken ct) + { + if (Sdk is not null) _transactionId = (await Sdk.BeginTransactionAsync(ct)).TransactionId; + else _transaction = await Connection!.BeginTransactionAsync(ct); + } + + public async Task ExecuteAsync(string sql, CancellationToken ct) + { + if (Sdk is not null) Assert.Null((await ExecuteSdkAsync(sql, ct)).Error); + else + { + using var command = CreateCommand(sql); + await command.ExecuteNonQueryAsync(ct); + } + } + + public async Task ScalarAsync(string sql, CancellationToken ct) + { + if (Sdk is null) + { + using var command = CreateCommand(sql); + return Convert.ToInt64(await command.ExecuteScalarAsync(ct)); + } + var result = await ExecuteSdkAsync(sql, ct); + Assert.Null(result.Error); + return Assert.IsType(Assert.Single(Assert.Single(result.Rows!))); + } + + public async Task CommitAsync(CancellationToken ct) + { + if (Sdk is not null) await Sdk.CommitTransactionAsync(_transactionId!, ct); + else await _transaction!.CommitAsync(ct); + _transactionId = null; + if (_transaction is not null) await _transaction.DisposeAsync(); + _transaction = null; + } + + public async Task RollbackAsync(CancellationToken ct) + { + if (Sdk is not null) await Sdk.RollbackTransactionAsync(_transactionId!, ct); + else await _transaction!.RollbackAsync(ct); + _transactionId = null; + if (_transaction is not null) await _transaction.DisposeAsync(); + _transaction = null; + } + + private Task ExecuteSdkAsync(string sql, CancellationToken ct) + => _transactionId is null ? Sdk!.ExecuteSqlAsync(sql, ct) : Sdk!.ExecuteInTransactionAsync(_transactionId, sql, ct); + + private DbCommand CreateCommand(string sql) + { + var command = Connection!.CreateCommand(); + command.CommandText = sql; + command.Transaction = _transaction; + return command; + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) return; + // Returning the rental/connection is deliberately responsible for + // rollback, rather than explicitly disposing an abandoned transaction. + if (Sdk is not null) await Sdk.DisposeAsync(); + else await Connection!.DisposeAsync(); + if (_transaction is not null) await _transaction.DisposeAsync(); + } + } + + private static void Decorate(IServiceCollection services, CancellationGate gate) + { + var original = services.Last(descriptor => descriptor.ServiceType == typeof(ICSharpDbClient)); + services.Replace(ServiceDescriptor.Singleton(provider => + { + var proxy = DispatchProxy.Create(); + var state = (GatedClient)(object)proxy; + state.Inner = (ICSharpDbClient)original.ImplementationFactory!(provider); + state.Gate = gate; + return proxy; + })); + } + + public sealed class CancellationGate + { + public const string BlockedSql = "SELECT 7001"; + public int BlockedCalls; + public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource RolledBack { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + } + + public class GatedClient : DispatchProxy + { + public ICSharpDbClient Inner = null!; + public CancellationGate Gate = null!; + + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + var method = targetMethod!; + if (method.Name == nameof(ICSharpDbClient.ExecuteSqlAsync) && (string)args![0]! == CancellationGate.BlockedSql) + return BlockAsync((CancellationToken)args[1]!); + object? result; + try { result = method.Invoke(Inner, args); } + catch (TargetInvocationException error) when (error.InnerException is not null) + { ExceptionDispatchInfo.Capture(error.InnerException).Throw(); throw; } + return method.Name == nameof(ICSharpDbClient.RollbackTransactionAsync) + ? ObserveRollbackAsync((Task)result!) : result; + } + + private async Task BlockAsync(CancellationToken ct) + { + Interlocked.Increment(ref Gate.BlockedCalls); + Gate.Entered.TrySetResult(); + await Gate.Release.Task.WaitAsync(ct); + return await Inner.ExecuteSqlAsync(CancellationGate.BlockedSql, ct); + } + + private async Task ObserveRollbackAsync(Task rollback) + { + await rollback; + Gate.RolledBack.TrySetResult(); + } + } +} diff --git a/tests/CSharpDB.Data.Tests/NativeGrpcConnectionTests.cs b/tests/CSharpDB.Data.Tests/NativeGrpcConnectionTests.cs new file mode 100644 index 00000000..c07d6d3e --- /dev/null +++ b/tests/CSharpDB.Data.Tests/NativeGrpcConnectionTests.cs @@ -0,0 +1,39 @@ +using CSharpDB.Testing; + +namespace CSharpDB.Data.Tests; + +public sealed class NativeGrpcConnectionTests +{ + [Fact] + public async Task NativeMode_ConnectionStringUsesRealHttp2Socket() + { + var ct = TestContext.Current.CancellationToken; + await using var host = new GrpcSocketDaemonFixture(); + await using var conn = new CSharpDbConnection( + $"Transport=Grpc;Grpc Mode=Native;Endpoint={host.NativeEndpoint}"); + await conn.OpenAsync(ct); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT @value"; + cmd.Parameters.AddWithValue("@value", 42); + Assert.Equal(42, Assert.IsType(await cmd.ExecuteScalarAsync(ct))); + Assert.NotEmpty(host.Snapshot()); + Assert.All(host.Snapshot(), pair => Assert.Equal("HTTP/2", pair.Key.Protocol)); + } + + [Theory] + [InlineData("Data Source=:memory:;Grpc Mode=Native")] + [InlineData("Data Source=:memory:;Pooling=true;Grpc Mode=Native")] + [InlineData("Transport=Http;Endpoint=http://localhost;Grpc Mode=Native")] + public async Task NativeMode_RejectsNonGrpcConnections(string connectionString) + { + await using var conn = new CSharpDbConnection(connectionString); + await Assert.ThrowsAsync( + () => conn.OpenAsync(TestContext.Current.CancellationToken)); + } + + [Theory] + [InlineData("unsupported")] + [InlineData("42")] + public void GrpcMode_RejectsUnknownConnectionStringValues(string value) + => Assert.Throws(() => new CSharpDbConnectionStringBuilder($"Grpc Mode={value}").GrpcMode); +} diff --git a/tests/CSharpDB.Data.Tests/NetworkResilienceTests.cs b/tests/CSharpDB.Data.Tests/NetworkResilienceTests.cs new file mode 100644 index 00000000..976035e3 --- /dev/null +++ b/tests/CSharpDB.Data.Tests/NetworkResilienceTests.cs @@ -0,0 +1,305 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using CSharpDB.Client; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace CSharpDB.Data.Tests; + +[Collection("ConnectionPoolState")] +public sealed class NetworkResilienceTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Theory] + [InlineData(CSharpDbGrpcMode.Auto, false)] + [InlineData(CSharpDbGrpcMode.Auto, true)] + [InlineData(CSharpDbGrpcMode.Native, false)] + [InlineData(CSharpDbGrpcMode.Native, true)] + public async Task Tls_PooledSdkAndAdo_UseHttp2AndPreserveCallerHttpClient(CSharpDbGrpcMode mode, bool ado) + { + await using var host = new RecoveryHost(mode, tls: true); + using var http = host.CreatePinnedHttpClient(); + var options = new CSharpDbClientOptions + { + Transport = CSharpDbTransport.Grpc, GrpcMode = mode, + Endpoint = host.Endpoint.AbsoluteUri, HttpClient = http, + }; + string cs = ConnectionString(host, mode, 1); + try + { + for (int i = 0; i < 3; i++) + { + if (ado) + { + await using var connection = new CSharpDbConnection(cs, http); + await connection.OpenAsync(Ct); + using var command = connection.CreateCommand(); command.CommandText = "SELECT 42"; + Assert.Equal(42L, Convert.ToInt64(await command.ExecuteScalarAsync(Ct))); + } + else + { + await using var client = await CSharpDbClient.RentAsync(options, 1, Ct); + Assert.Equal(42L, (await client.ExecuteSqlAsync("SELECT 42", Ct)).Rows![0][0]); + } + } + Assert.Equal(1, host.Count("GetInfo")); + Assert.Equal(3, host.Count("ExecuteSql")); + Assert.All(host.Requests.Keys, shape => + { + Assert.Equal("HTTP/2", shape.Protocol); + Assert.StartsWith("application/grpc", shape.ContentType, StringComparison.Ordinal); + Assert.DoesNotContain("grpc-web", shape.ContentType, StringComparison.Ordinal); + }); + await CSharpDbClient.ClearPoolAsync(host.Endpoint.AbsoluteUri); + using var request = new HttpRequestMessage(HttpMethod.Get, host.Endpoint) + { Version = HttpVersion.Version20, VersionPolicy = HttpVersionPolicy.RequestVersionExact }; + using var response = await http.SendAsync(request, Ct); + Assert.Equal(HttpVersion.Version20, response.Version); + } + finally { await CSharpDbClient.ClearPoolAsync(host.Endpoint.AbsoluteUri); } + } + + [Fact] + public async Task Tls_UntrustedCertificate_IsRejected() + { + await using var host = new RecoveryHost(CSharpDbGrpcMode.Native, tls: true); + var options = new CSharpDbClientOptions + { + Transport = CSharpDbTransport.Grpc, GrpcMode = CSharpDbGrpcMode.Native, + Endpoint = host.Endpoint.AbsoluteUri, + }; + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(Ct); + timeout.CancelAfter(TimeSpan.FromSeconds(10)); + try + { + await Assert.ThrowsAsync(() => CSharpDbClient.RentAsync(options, 1, timeout.Token).AsTask()); + Assert.Equal(0, host.Count("GetInfo")); + } + finally { await CSharpDbClient.ClearPoolAsync(host.Endpoint.AbsoluteUri); } + } + + [Theory] + [InlineData(CSharpDbGrpcMode.Auto, false)] + [InlineData(CSharpDbGrpcMode.Auto, true)] + [InlineData(CSharpDbGrpcMode.Native, false)] + [InlineData(CSharpDbGrpcMode.Native, true)] + public async Task ServerRestart_DiscardsFailedRental_AndDoesNotReplayFailedWrite(CSharpDbGrpcMode mode, bool ado) + { + await using var host = new RecoveryHost(mode); + string cs = ConnectionString(host, mode, 1); + var options = new CSharpDbClientOptions + { Transport = CSharpDbTransport.Grpc, GrpcMode = mode, Endpoint = host.Endpoint.AbsoluteUri }; + const string setup = "CREATE TABLE recovery (id INTEGER PRIMARY KEY); INSERT INTO recovery VALUES (1)"; + try + { + if (ado) + { + await using var connection = new CSharpDbConnection(cs); + await connection.OpenAsync(Ct); + using var command = connection.CreateCommand(); command.CommandText = setup; + await command.ExecuteNonQueryAsync(Ct); + } + else + { + await using var client = await CSharpDbClient.RentAsync(options, 1, Ct); + Assert.Null((await client.ExecuteSqlAsync(setup, Ct)).Error); + } + await host.StopAsync(); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(Ct); + timeout.CancelAfter(TimeSpan.FromSeconds(5)); + if (ado) + { + await using var failed = new CSharpDbConnection(cs); + await failed.OpenAsync(timeout.Token); + using var command = failed.CreateCommand(); command.CommandText = "INSERT INTO recovery VALUES (2)"; + await Assert.ThrowsAnyAsync(() => command.ExecuteNonQueryAsync(timeout.Token)); + } + else + { + await using var failed = await CSharpDbClient.RentAsync(options, 1, timeout.Token); + await Assert.ThrowsAnyAsync(() => failed.ExecuteSqlAsync("INSERT INTO recovery VALUES (2)", timeout.Token)); + } + host.Start(); + if (ado) + { + await using var recovered = new CSharpDbConnection(cs); + await recovered.OpenAsync(Ct); + using var command = recovered.CreateCommand(); command.CommandText = "SELECT COUNT(*) FROM recovery"; + Assert.Equal(1L, await command.ExecuteScalarAsync(Ct)); + } + else + { + await using var recovered = await CSharpDbClient.RentAsync(options, 1, Ct); + var result = await recovered.ExecuteSqlAsync("SELECT COUNT(*) FROM recovery", Ct); + Assert.Null(result.Error); Assert.Equal(1L, result.Rows![0][0]); + } + Assert.Equal(2, host.Count("GetInfo")); + Assert.Equal(2, host.Count("ExecuteSql")); + } + finally { await CSharpDbClient.ClearPoolAsync(host.Endpoint.AbsoluteUri); } + } + + [Theory] + [InlineData(CSharpDbGrpcMode.Auto)] + [InlineData(CSharpDbGrpcMode.Native)] + public async Task SharedPool_64SdkAndAdoCallers_SurviveRepeatedRetirement(CSharpDbGrpcMode mode) + { + await using var host = new RecoveryHost(mode); + string cs = ConnectionString(host, mode, 8); + var options = new CSharpDbClientOptions + { Transport = CSharpDbTransport.Grpc, GrpcMode = mode, Endpoint = host.Endpoint.AbsoluteUri }; + var start = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(Ct); + deadline.CancelAfter(TimeSpan.FromSeconds(60)); + CancellationToken pressureToken = deadline.Token; + try + { + Task reads = Task.WhenAll(Enumerable.Range(0, 64).Select(async lane => + { + await start.Task; + for (int i = 0; i < 256; i++) + { + if (lane % 2 == 0) + { + await using var client = await CSharpDbClient.RentAsync(options, 8, pressureToken); + var result = await client.ExecuteSqlAsync("SELECT 42", pressureToken); + Assert.Null(result.Error); Assert.Equal(42L, result.Rows![0][0]); + } + else + { + await using var connection = new CSharpDbConnection(cs); + await connection.OpenAsync(pressureToken); + using var command = connection.CreateCommand(); command.CommandText = "SELECT 42"; + Assert.Equal(42L, Convert.ToInt64(await command.ExecuteScalarAsync(pressureToken))); + } + } + })); + start.SetResult(); + int clears = 0; + while (!reads.IsCompleted) + { + await CSharpDbClient.ClearPoolAsync(host.Endpoint.AbsoluteUri); + clears++; + await Task.Delay(20, pressureToken); + } + await reads.WaitAsync(TimeSpan.FromSeconds(60), Ct); + Assert.True(clears > 0); + Assert.Equal(64 * 256, host.Count("ExecuteSql")); + await using var final = await CSharpDbClient.RentAsync(options, 8, Ct); + Assert.Equal(42L, (await final.ExecuteSqlAsync("SELECT 42", Ct)).Rows![0][0]); + } + finally { await CSharpDbClient.ClearPoolAsync(host.Endpoint.AbsoluteUri); } + } + + private static string ConnectionString(RecoveryHost host, CSharpDbGrpcMode mode, int capacity) + => $"Transport=Grpc;Endpoint={host.Endpoint};Pooling=true;Max Pool Size={capacity};Grpc Mode={mode}"; + + private sealed class RecoveryHost : IAsyncDisposable + { + private readonly string _path = Path.Combine(Path.GetTempPath(), $"csharpdb_recovery_{Guid.NewGuid():N}.db"); + private readonly CSharpDbGrpcMode _mode; + private readonly X509Certificate2? _certificate; + private Factory? _factory; + public Uri Endpoint { get; private set; } = null!; + public ConcurrentDictionary Requests { get; } = new(); + + public RecoveryHost(CSharpDbGrpcMode mode, bool tls = false) + { + _mode = mode; + if (tls) + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest("CN=localhost", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var names = new SubjectAlternativeNameBuilder(); names.AddDnsName("localhost"); names.AddIpAddress(IPAddress.Loopback); + request.CertificateExtensions.Add(names.Build()); + using var generated = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddMinutes(30)); + _certificate = X509CertificateLoader.LoadPkcs12(generated.Export(X509ContentType.Pfx), null, X509KeyStorageFlags.DefaultKeySet); + } + Start(); + } + public void Start() + { + if (_factory is not null) throw new InvalidOperationException("Already running."); + _factory = new Factory(_path, Requests); + _factory.UseKestrel(options => options.Listen(IPAddress.Loopback, Endpoint?.Port ?? 0, listener => + { + listener.Protocols = _certificate is not null || _mode == CSharpDbGrpcMode.Native ? HttpProtocols.Http2 : HttpProtocols.Http1; + if (_certificate is not null) listener.UseHttps(_certificate); + })); + Endpoint = new Uri(_factory.Services.GetRequiredService().Features.Get()!.Addresses.Single()); + } + public async Task StopAsync() + { + if (_factory is null) return; + var factory = _factory; _factory = null; await factory.DisposeAsync(); + } + public HttpClient CreatePinnedHttpClient() + { + string fingerprint = _certificate!.GetCertHashString(HashAlgorithmName.SHA256); + return new HttpClient(new SocketsHttpHandler + { + SslOptions = new() { RemoteCertificateValidationCallback = (_, certificate, _, _) => + certificate?.GetCertHashString(HashAlgorithmName.SHA256) == fingerprint }, + }); + } + public long Count(string method) => Requests.Where(p => p.Key.Method.EndsWith("/" + method, StringComparison.Ordinal)).Sum(p => p.Value); + public async ValueTask DisposeAsync() + { + await StopAsync(); _certificate?.Dispose(); + foreach (string path in new[] { _path, _path + ".wal" }) + try { File.Delete(path); } catch (IOException) { } + } + public readonly record struct RequestShape(string Protocol, string ContentType, string Method); + + private sealed class Capture(ConcurrentDictionary requests) : IStartupFilter + { + public Action Configure(Action next) => app => + { + app.Use(async (context, continuation) => + { + if (context.Request.Path.StartsWithSegments("/csharpdb.rpc.CSharpDbRpc")) + requests.AddOrUpdate(new(context.Request.Protocol, context.Request.ContentType ?? "", context.Request.Path.Value!), 1, static (_, n) => n + 1); + await continuation(); + }); + next(app); + }; + } + private sealed class Factory(string path, ConcurrentDictionary requests) : WebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Development"); + var settings = new Dictionary + { + ["ConnectionStrings:CSharpDB"] = $"Data Source={path}", + ["CSharpDB:Observability:Enabled"] = "false", + ["CSharpDB:Observability:Health:Enabled"] = "false", + ["CSharpDB:Daemon:Security:Mode"] = "None", + }; + builder.ConfigureAppConfiguration((_, configuration) => configuration.AddInMemoryCollection(settings)); + builder.ConfigureLogging(logging => logging.ClearProviders()); + builder.ConfigureServices(services => + { + services.AddSingleton(new Capture(requests)); + services.AddHostedService(); + }); + } + } + private sealed class Shutdown(ICSharpDbClient client) : IHostedService + { + public Task StartAsync(CancellationToken ct) => Task.CompletedTask; + public Task StopAsync(CancellationToken ct) => client.DisposeAsync().AsTask(); + } + } +} diff --git a/tests/CSharpDB.Data.Tests/PrivateScalarProjectionTests.cs b/tests/CSharpDB.Data.Tests/PrivateScalarProjectionTests.cs new file mode 100644 index 00000000..9a7ffe71 --- /dev/null +++ b/tests/CSharpDB.Data.Tests/PrivateScalarProjectionTests.cs @@ -0,0 +1,650 @@ +using System.Reflection; +using CSharpDB.Client; +using CSharpDB.Client.Internal; +using CSharpDB.Client.Models; +using CSharpDB.Execution; +using ClientColumn = CSharpDB.Client.Models.ColumnDefinition; +using ClientDbType = CSharpDB.Client.Models.DbType; +using ClientSqlKind = CSharpDB.Client.Models.SqlTypeKind; +using ClientSqlType = CSharpDB.Client.Models.SqlTypeDescriptor; +using ScalarProxy = CSharpDB.Data.Tests.RemoteScalarProjectionUnitTests.ScalarClientProxy; +using SqlDispatch = CSharpDB.Data.Tests.RemoteScalarProjectionUnitTests.Dispatch; + +namespace CSharpDB.Data.Tests; + +public sealed class PrivateScalarProjectionTests +{ + private const string Sql = "INSERT INTO exact_script VALUES (1); SELECT value FROM exact_script"; + private static readonly CompactCase[] CompactCases = CreateCompactCases(); + + public static IEnumerable CompactCaseIndices + => Enumerable.Range(0, CompactCases.Length).Select(index => new object[] { index }); + + public static IEnumerable ValidFacetCaseIndices + => Enumerable.Range(0, 8).Select(index => new object[] { index }); + + public static IEnumerable InvalidFacetCaseIndices + => Enumerable.Range(0, 16).Select(index => new object[] { index }); + + public static IEnumerable FallbackCaseIndices + => Enumerable.Range(0, 10).Select(index => new object[] { index }); + + [Theory] + [MemberData(nameof(CompactCaseIndices))] + public async Task CompactInteger_MatchesMaterializedAndLegacyRoutes(int shape) + { + CompactCase item = CompactCases[shape]; + var token = TestContext.Current.CancellationToken; + Outcome expected = await OracleAsync(Query([item.Value], item.Type), token); + foreach (bool pending in new[] { false, true }) + { + Outcome actual = await ProjectedAsync( + ScalarProjectionResult.FromCompactInt64(item.Value, Snapshot(item.Type)), token, pending); + Equivalent(expected, actual, token); + } + } + + [Theory] + [MemberData(nameof(ValidFacetCaseIndices))] + public async Task ValidFacetBoundaries_RetainExistingClrConversions(int shape) + { + ClientSqlType type = shape switch + { + 0 => Type(ClientSqlKind.Decimal, precision: 1, scale: 0), + 1 => Type(ClientSqlKind.Decimal, precision: 18, scale: 18), + 2 => Type(ClientSqlKind.Char, length: 1), + 3 => Type(ClientSqlKind.VarBinary, length: int.MaxValue), + 4 => Type(ClientSqlKind.Time, fractionalSecondsPrecision: 0), + 5 => Type(ClientSqlKind.Timestamp, fractionalSecondsPrecision: 7), + 6 => Type(ClientSqlKind.Bit, length: 1), + _ => Type(ClientSqlKind.IntervalDayToSecond, fractionalSecondsPrecision: 7), + }; + var token = TestContext.Current.CancellationToken; + Outcome expected = await OracleAsync(Query([1L], type), token); + // An incompatible CLR conversion may fail after valid facet metadata; + // valid metadata must not acquire a new facet validation failure. + Assert.False(expected.Error is ArgumentException); + foreach (bool pending in new[] { false, true }) + Equivalent(expected, await ProjectedAsync( + ScalarProjectionResult.FromCompactInt64(1, Snapshot(type)), token, pending), token); + } + + [Theory] + [MemberData(nameof(InvalidFacetCaseIndices))] + public async Task InvalidFacetsAndKinds_WinBeforeCancellationAndClrOverflow(int shape) + { + ClientSqlType type = InvalidType(shape); + foreach (bool canceled in new[] { false, true }) + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + if (canceled) cancellation.Cancel(); + Outcome expected = await OracleAsync(Query([2147483648L], type), cancellation.Token); + Assert.NotNull(expected.Error); + Assert.False(expected.Error is OperationCanceledException or OverflowException); + foreach (bool pending in new[] { false, true }) + { + Outcome actual = await ProjectedAsync( + ScalarProjectionResult.FromCompactInt64(2147483648L, Snapshot(type)), cancellation.Token, pending); + Equivalent(expected, actual, cancellation.Token); + } + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CompletedReply_CancellationBoundaryStillPrecedesCheckedClrConversion(bool cancelAtResponse) + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + ClientSqlType type = Type(ClientSqlKind.Integer); + Outcome actual = await ProjectedAsync( + ScalarProjectionResult.FromCompactInt64(2147483648L, Snapshot(type)), cancellation.Token, + pending: false, onResponse: () => { if (cancelAtResponse) cancellation.Cancel(); }); + Outcome expected = await OracleAsync(Query([2147483648L], type), cancellation.Token); + Equivalent(expected, actual, cancellation.Token); + if (cancelAtResponse) + Assert.IsAssignableFrom(actual.Error); + else + Assert.IsType(actual.Error); + } + + [Theory] + [MemberData(nameof(FallbackCaseIndices))] + public async Task FullFallback_UsesTheAlreadyReceivedResponseWithoutReplay(int shape) + { + ClientSqlType integer = Type(ClientSqlKind.Integer); + SqlExecutionResult response = shape switch + { + 0 => Query([42L], integer), + 1 => new() { IsQuery = true, ColumnNames = ["value"], ColumnTypes = ["INTEGER"], Rows = [[42L]] }, + 2 => Query(["text scalar"], Type(ClientSqlKind.Text), ClientDbType.Text), + 3 => new() + { + IsQuery = true, ColumnNames = ["value"], Columns = [Column(integer)], + Rows = [[42L], [new ExplodingValue()]], + }, + 4 => new() + { + IsQuery = true, ColumnNames = ["value", "unused"], + Columns = [Column(integer), new ClientColumn { Name = "unused", Type = ClientDbType.Text }], + Rows = [[42L, new ExplodingValue()]], + }, + 5 => new() + { + IsQuery = true, ColumnNames = ["value"], Columns = [Column(InvalidType(0))], + Rows = [[new ExplodingValue()]], Error = "SQL failed before projection", + }, + 6 => new() { IsQuery = false, RowsAffected = 1 }, + 7 => new() { IsQuery = true, ColumnNames = [], Columns = [], Rows = [] }, + 8 => new() { IsQuery = true, ColumnNames = ["value"], Columns = [Column(integer)], Rows = [] }, + // Null still requires declared facet validation, even when canceled. + _ => Query([null], InvalidType(0)), + }; + foreach (bool canceled in new[] { false, true }) + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + if (canceled) cancellation.Cancel(); + Outcome expected = await OracleAsync(response, cancellation.Token); + foreach (bool pending in new[] { false, true }) + Equivalent(expected, await ProjectedAsync( + ScalarProjectionResult.FromFallback(response), cancellation.Token, pending), cancellation.Token); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CapabilityFalse_SelectsTheExistingRouteBeforeDispatch(bool inTransaction) + { + var token = TestContext.Current.CancellationToken; + var (client, proxy) = CapableClient(); + proxy.Supported = false; + proxy.Response = () => Task.FromResult(Query([42L], Type(ClientSqlKind.Integer))); + proxy.ProjectionResponse = () => throw new InvalidOperationException("Unsupported projection was dispatched."); + await using var session = new RemoteDatabaseSession(client); + if (inTransaction) await session.BeginTransactionAsync(token); + Assert.Equal(42, Assert.IsType(await session.ExecuteScalarAsync(Sql, token))); + Assert.Empty(proxy.ProjectionDispatches); + SqlDispatch dispatch = Assert.Single(proxy.Dispatches); + Assert.Equal(inTransaction ? nameof(ICSharpDbClient.ExecuteInTransactionAsync) : nameof(ICSharpDbClient.ExecuteSqlAsync), dispatch.Method); + Assert.Equal(inTransaction ? "scalar-transaction" : null, dispatch.TransactionId); + Assert.Equal(Sql, dispatch.Sql); + Assert.Equal(token, dispatch.Token); + Assert.Equal(1, proxy.SupportChecks); + if (inTransaction) await session.CommitAsync(token); + } + + [Fact] + public async Task Projection_ForwardsExactScriptTokenAndTransactionAcrossRentalLifetime() + { + var token = TestContext.Current.CancellationToken; + var (client, proxy) = CapableClient(); + proxy.ProjectionResponse = () => Task.FromResult( + ScalarProjectionResult.FromCompactInt64(42, Snapshot(Type(ClientSqlKind.Integer)))); + var session = new RemoteDatabaseSession(client); + try + { + await session.BeginTransactionAsync(token); + Assert.Equal(42, Assert.IsType(await session.ExecuteScalarAsync(Sql, token))); + await session.CommitAsync(token); + Assert.Equal(42, Assert.IsType(await session.ExecuteScalarAsync(Sql, token))); + await session.BeginTransactionAsync(token); + Assert.Equal(42, Assert.IsType(await session.ExecuteScalarAsync(Sql, token))); + } + finally { await session.DisposeAsync(); } + Assert.Empty(proxy.Dispatches); + Assert.Equal(3, proxy.ProjectionDispatches.Count); + Assert.Equal(new string?[] { "scalar-transaction", null, "scalar-transaction" }, + proxy.ProjectionDispatches.Select(dispatch => dispatch.TransactionId)); + Assert.All(proxy.ProjectionDispatches, dispatch => + { + Assert.Equal(Sql, dispatch.Sql); + Assert.Equal(token, dispatch.Token); + }); + Assert.Equal(2, proxy.Begins); + Assert.Equal(1, proxy.Commits); + Assert.Equal(1, proxy.Rollbacks); + Assert.Equal(1, proxy.Disposals); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public async Task ProjectionFailures_PropagateWithoutPublicRedispatch(int shape) + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + if (shape == 3) cancellation.Cancel(); + var injected = new InvalidOperationException("The first SQL dispatch failed; it must not be replayed."); + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var (client, proxy) = CapableClient(); + proxy.ProjectionResponse = shape switch + { + 0 => () => throw injected, + 1 => () => Task.FromException(injected), + 2 => () => pending.Task, + _ => () => Task.FromCanceled(cancellation.Token), + }; + await using var session = new RemoteDatabaseSession(client); + Task operation = session.ExecuteScalarAsync(Sql, cancellation.Token).AsTask(); + if (shape == 2) + { + Assert.False(operation.IsCompleted); + pending.SetException(injected); + } + Outcome actual = await ObserveAsync(() => operation); + if (shape == 3) + Assert.Equal(cancellation.Token, Assert.IsAssignableFrom(actual.Error).CancellationToken); + else + Assert.Same(injected, actual.Error); + AssertProjectionDispatch(proxy, cancellation.Token); + } + + [Fact] + public async Task InvalidProjectionEnvelope_FailsWithoutExecutingSqlAgain() + { + Outcome actual = await ProjectedAsync(default, TestContext.Current.CancellationToken, pending: false); + Assert.Equal("The scalar projection did not return a result.", Assert.IsType(actual.Error).Message); + } + + [Fact] + public async Task ClosedSession_RejectsBeforeCapabilityOrSqlDispatch() + { + var (client, proxy) = CapableClient(); + var session = new RemoteDatabaseSession(client); + await session.DisposeAsync(); + var error = await Assert.ThrowsAsync(async () => + { + await session.ExecuteScalarAsync(Sql, TestContext.Current.CancellationToken); + }); + Assert.Equal("Session is closed.", error.Message); + Assert.Equal(0, proxy.SupportChecks); + Assert.Empty(proxy.ProjectionDispatches); + Assert.Empty(proxy.Dispatches); + Assert.Equal(1, proxy.Disposals); + } + + [Fact] + public async Task DataScalar_RepeatedMixedCompletions_DoNotRetainPriorValueFailureOrCancellation() + { + var (client, proxy) = CapableClient(); + await using var session = new RemoteDatabaseSession(client); + var retained = new List<(DataWork Work, Outcome Actual)>(); + try + { + for (int index = 0; index < 96; index++) + { + DataWork work = await CreateDataWorkAsync(index); + retained.Add((work, null!)); + bool pending = (index / 12) % 2 != 0; + proxy.ProjectionResponse = () => pending ? work.Completion.Task : work.CompletedTask(); + ValueTask operation = session.ExecuteScalarAsync(work.Sql, work.Token); + if (pending) + { + Assert.False(operation.IsCompleted); + work.Complete(); + } + // The Data ValueTask is consumed exactly once, including when it faults. + Outcome actual = await ObserveValueTaskAsync(operation); + retained[^1] = (work, actual); + AssertDataWork(work, actual); + AssertDataDispatch(proxy, index, work); + } + foreach (var item in retained) AssertDataWork(item.Work, item.Actual); + Assert.Equal(96, proxy.SupportChecks); + Assert.Equal(96, proxy.ProjectionDispatches.Count); + Assert.Empty(proxy.Dispatches); + } + finally + { + foreach (var item in retained) item.Work.Dispose(); + } + } + + [Fact] + public async Task DataScalar_ConcurrentMixedCompletions_KeepIndependentResultsFailuresAndTokens() + { + var (client, proxy) = CapableClient(); + await using var session = new RemoteDatabaseSession(client); + var retained = new List<(DataWork Work, Outcome Actual)>(); + int dispatchIndex = 0; + for (int batch = 0; batch < 8; batch++) + { + var works = new List(); + var operations = new List>(); + try + { + for (int lane = 0; lane < 12; lane++) + { + DataWork work = await CreateDataWorkAsync(batch * 12 + lane); + works.Add(work); + proxy.ProjectionResponse = () => work.Completion.Task; + ValueTask operation = session.ExecuteScalarAsync(work.Sql, work.Token); + Assert.False(operation.IsCompleted); + operations.Add(ObserveValueTaskAsync(operation)); + AssertDataDispatch(proxy, dispatchIndex++, work); + } + Assert.All(operations, operation => Assert.False(operation.IsCompleted)); + // Alternate completion order so adjacent pooled state cannot stand in for a lane. + foreach (DataWork work in batch % 2 == 0 ? works.AsEnumerable().Reverse() : works) + work.Complete(); + Outcome[] actual = await Task.WhenAll(operations); + for (int lane = 0; lane < works.Count; lane++) + { + AssertDataWork(works[lane], actual[lane]); + retained.Add((works[lane], actual[lane])); + } + foreach (var item in retained) AssertDataWork(item.Work, item.Actual); + } + finally + { + // Even an assertion failure releases every deliberately suspended call. + foreach (DataWork work in works) work.Complete(); + await Task.WhenAll(operations); + foreach (DataWork work in works) work.Dispose(); + } + } + Assert.Equal(96, proxy.SupportChecks); + Assert.Equal(96, proxy.ProjectionDispatches.Count); + Assert.Empty(proxy.Dispatches); + } + + private static async Task CreateDataWorkAsync(int index) + { + var work = new DataWork(index); + int shape = index % 12; + ClientSqlType type = Type(ClientSqlKind.Integer); + long value = index + 17L; + SqlExecutionResult response; + switch (shape) + { + case 0: + response = Query([value], type); + work.Response = ScalarProjectionResult.FromCompactInt64(value, Snapshot(type)); + break; + case 1: + value = long.MaxValue - index; + type = Type(ClientSqlKind.BigInt); + response = Query([value], type); + work.Response = ScalarProjectionResult.FromCompactInt64(value, Snapshot(type)); + break; + case 2: + response = Query([$"retained text {index}"], Type(ClientSqlKind.Text), ClientDbType.Text); + work.Response = ScalarProjectionResult.FromFallback(response); + break; + case 3: + work.Cancellation.Cancel(); + response = new() { IsQuery = true, ColumnNames = ["value"], Columns = [Column(InvalidType(0))], + Rows = [[new ExplodingValue()]], Error = $"SQL failure {index} precedes conversion and cancellation" }; + work.Response = ScalarProjectionResult.FromFallback(response); + break; + case 4: + work.Cancellation.Cancel(); + response = Query([new ExplodingValue()], InvalidType(0)); + work.Response = ScalarProjectionResult.FromFallback(response); + break; + case 5: + work.Cancellation.Cancel(); + type = InvalidType(index % 16); + response = Query([2147483648L], type); + work.Response = ScalarProjectionResult.FromCompactInt64(2147483648L, Snapshot(type)); + break; + case 6: + work.Cancellation.Cancel(); + response = Query([2147483648L], type); + work.Response = ScalarProjectionResult.FromCompactInt64(2147483648L, Snapshot(type)); + break; + case 7: + response = Query([2147483648L], type); + work.Response = ScalarProjectionResult.FromCompactInt64(2147483648L, Snapshot(type)); + break; + case 8: + work.Failure = new InvalidOperationException($"Independent transport failure {index}"); + work.Expected = new(null, work.Failure); + return work; + case 9: + work.Cancellation.Cancel(); + response = Query([null], InvalidType(0)); + work.Response = ScalarProjectionResult.FromFallback(response); + break; + case 10: + response = new() { IsQuery = true, ColumnNames = ["value", "unused"], + Columns = [Column(type), new ClientColumn { Name = "unused", Type = ClientDbType.Text }], + Rows = [[value, new ExplodingValue()]] }; + work.Response = ScalarProjectionResult.FromFallback(response); + break; + default: + value = long.MinValue + index; + response = Query([value], null); + work.Response = ScalarProjectionResult.FromCompactInt64(value, null); + break; + } + work.Expected = await OracleAsync(response, work.Token); + return work; + } + + private static async Task ObserveValueTaskAsync(ValueTask operation) + { + try { return new(await operation, null); } + catch (Exception error) { return new(null, error); } + } + + private static void AssertDataWork(DataWork work, Outcome actual) + { + Equivalent(work.Expected, actual, work.Token); + if (work.Failure is not null) Assert.Same(work.Failure, actual.Error); + } + + private static void AssertDataDispatch(ProjectionProxy proxy, int index, DataWork work) + { + SqlDispatch dispatch = proxy.ProjectionDispatches[index]; + Assert.Equal(nameof(IScalarProjectionClient.ExecuteScalarProjectionAsync), dispatch.Method); + Assert.Equal(work.Sql, dispatch.Sql); + Assert.Null(dispatch.TransactionId); + Assert.Equal(work.Token, dispatch.Token); + Assert.Empty(proxy.Dispatches); + } + + private sealed class DataWork : IDisposable + { + internal DataWork(int index) + { + Sql = $"{PrivateScalarProjectionTests.Sql}; -- reuse-{index}"; + Cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + Token = Cancellation.Token; + } + internal readonly string Sql; + internal readonly CancellationTokenSource Cancellation; + internal readonly TaskCompletionSource Completion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + internal CancellationToken Token { get; } + internal ScalarProjectionResult Response; + internal Exception? Failure; + internal Outcome Expected = null!; + internal Task CompletedTask() => Failure is null + ? Task.FromResult(Response) : Task.FromException(Failure); + internal void Complete() + { + if (Failure is not null) Completion.TrySetException(Failure); + else Completion.TrySetResult(Response); + } + public void Dispose() => Cancellation.Dispose(); + } + + private static CompactCase[] CreateCompactCases() + { + var cases = Enum.GetValues() + .Select(kind => new CompactCase(42L, Type(kind))).ToList(); + cases.Insert(0, new(42L, null)); + cases.AddRange([ + new(long.MinValue, null), new(long.MaxValue, null), + new(0, Type(ClientSqlKind.Boolean)), + new(0, Type(ClientSqlKind.TinyInt)), new(byte.MaxValue, Type(ClientSqlKind.TinyInt)), + new(-1, Type(ClientSqlKind.TinyInt)), new((long)byte.MaxValue + 1, Type(ClientSqlKind.TinyInt)), + new(short.MinValue, Type(ClientSqlKind.SmallInt)), new(short.MaxValue, Type(ClientSqlKind.SmallInt)), + new((long)short.MinValue - 1, Type(ClientSqlKind.SmallInt)), new((long)short.MaxValue + 1, Type(ClientSqlKind.SmallInt)), + new(int.MinValue, Type(ClientSqlKind.Integer)), new(int.MaxValue, Type(ClientSqlKind.Integer)), + new((long)int.MinValue - 1, Type(ClientSqlKind.Integer)), new((long)int.MaxValue + 1, Type(ClientSqlKind.Integer)), + new(long.MinValue, Type(ClientSqlKind.BigInt)), new(long.MaxValue, Type(ClientSqlKind.BigInt)), + ]); + return cases.ToArray(); + } + + private static ClientSqlType InvalidType(int shape) => shape switch + { + 0 => Type(ClientSqlKind.Integer, length: 1), + 1 => Type(ClientSqlKind.VarChar, length: 0), + 2 => Type(ClientSqlKind.VarBinary, length: -1), + 3 => Type(ClientSqlKind.Integer, precision: 1), + 4 => Type(ClientSqlKind.Integer, scale: 0), + 5 => Type(ClientSqlKind.Decimal, precision: 0), + 6 => Type(ClientSqlKind.Decimal, precision: 19), + 7 => Type(ClientSqlKind.Decimal, scale: 0), + 8 => Type(ClientSqlKind.Decimal, precision: 3, scale: -1), + 9 => Type(ClientSqlKind.Decimal, precision: 3, scale: 4), + 10 => Type(ClientSqlKind.Integer, fractionalSecondsPrecision: 0), + 11 => Type(ClientSqlKind.Time, fractionalSecondsPrecision: -1), + 12 => Type(ClientSqlKind.Timestamp, fractionalSecondsPrecision: 8), + 13 => Type((ClientSqlKind)255), + 14 => Type(ClientSqlKind.VarChar, length: 0, precision: 1), + _ => Type(ClientSqlKind.Decimal, length: 1, precision: 0), + }; + + private static ClientSqlType Type(ClientSqlKind kind, int? length = null, int? precision = null, + int? scale = null, int? fractionalSecondsPrecision = null) + => new() { Kind = kind, Length = length, Precision = precision, Scale = scale, + FractionalSecondsPrecision = fractionalSecondsPrecision }; + + private static ScalarTypeSnapshot? Snapshot(ClientSqlType? type) + => type is null ? null : new(type.Kind, type.Length, type.Precision, type.Scale, type.FractionalSecondsPrecision); + + private static ClientColumn Column(ClientSqlType? type, ClientDbType physical = ClientDbType.Integer) + => new() { Name = "value", Type = physical, DeclaredType = type }; + + private static SqlExecutionResult Query(object?[] row, ClientSqlType? type, ClientDbType physical = ClientDbType.Integer) + => new() { IsQuery = true, ColumnNames = ["value"], Columns = [Column(type, physical)], Rows = [row] }; + + private static async Task OracleAsync(SqlExecutionResult response, CancellationToken token) + { + Outcome materialized = await LegacyAsync(response, token, materialize: true); + Outcome legacy = await LegacyAsync(response, token, materialize: false); + Equivalent(materialized, legacy, token); + return materialized; + } + + private static async Task LegacyAsync(SqlExecutionResult response, CancellationToken token, bool materialize) + { + ICSharpDbClient client = DispatchProxy.Create(); + var proxy = (ScalarProxy)client; + proxy.Response = () => Task.FromResult(response); + await using var session = new RemoteDatabaseSession(client); + Outcome outcome = await ObserveAsync(async () => + { + if (!materialize) return await session.ExecuteScalarAsync(Sql, token); + await using QueryResult result = await session.ExecuteAsync(Sql, token); + if (!result.IsQuery || result.Schema.Length == 0 || !await result.MoveNextAsync(token)) return null; + var column = result.Schema[0]; + return column.DeclaredType is null + ? TypeMapper.GetClrValue(result.Current[0]) + : TypeMapper.GetClrValue(result.Current[0], column.EffectiveType); + }); + SqlDispatch dispatch = Assert.Single(proxy.Dispatches); + Assert.Equal(nameof(ICSharpDbClient.ExecuteSqlAsync), dispatch.Method); + Assert.Equal(Sql, dispatch.Sql); + Assert.Null(dispatch.TransactionId); + Assert.Equal(token, dispatch.Token); + return outcome; + } + + private static async Task ProjectedAsync(ScalarProjectionResult response, CancellationToken token, + bool pending, Action? onResponse = null) + { + var (client, proxy) = CapableClient(); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + proxy.ProjectionResponse = () => + { + Task task = pending ? completion.Task : Task.FromResult(response); + onResponse?.Invoke(); // A completed reply exists before cancellation is observed by Data. + return task; + }; + await using var session = new RemoteDatabaseSession(client); + Task operation = session.ExecuteScalarAsync(Sql, token).AsTask(); + if (pending) + { + Assert.False(operation.IsCompleted); + completion.SetResult(response); + } + Outcome outcome = await ObserveAsync(() => operation); + AssertProjectionDispatch(proxy, token); + return outcome; + } + + private static void AssertProjectionDispatch(ProjectionProxy proxy, CancellationToken token) + { + Assert.Empty(proxy.Dispatches); + SqlDispatch dispatch = Assert.Single(proxy.ProjectionDispatches); + Assert.Equal(nameof(IScalarProjectionClient.ExecuteScalarProjectionAsync), dispatch.Method); + Assert.Equal(Sql, dispatch.Sql); + Assert.Null(dispatch.TransactionId); + Assert.Equal(token, dispatch.Token); + Assert.Equal(1, proxy.SupportChecks); + } + + private static async Task ObserveAsync(Func> operation) + { + try { return new(await operation(), null); } + catch (Exception error) { return new(null, error); } + } + + private static void Equivalent(Outcome expected, Outcome actual, CancellationToken token) + { + if (expected.Error is not { } expectedError) + { + Assert.Null(actual.Error); + Assert.Equal(expected.Value?.GetType(), actual.Value?.GetType()); + Assert.Equal(expected.Value, actual.Value); + return; + } + Exception error = Assert.IsAssignableFrom(actual.Error); + Assert.Equal(expectedError.GetType(), error.GetType()); + Assert.Equal(expectedError.Message, error.Message); + if (expectedError is ArgumentException expectedArgument) + Assert.Equal(expectedArgument.ParamName, Assert.IsAssignableFrom(error).ParamName); + if (error is OperationCanceledException canceled) + Assert.Equal(token, canceled.CancellationToken); + } + + private static (ICSharpDbClient Client, ProjectionProxy Proxy) CapableClient() + { + ICSharpDbClient client = DispatchProxy.Create(); + var proxy = (ProjectionProxy)client; + proxy.Response = () => throw new InvalidOperationException("SQL was replayed through the public client."); + return (client, proxy); + } + + private sealed record CompactCase(long Value, ClientSqlType? Type); + private sealed record Outcome(object? Value, Exception? Error); + private sealed class ExplodingValue + { + public override string ToString() => throw new InvalidOperationException("Unused fallback cell was converted."); + } + + public class ProjectionProxy : ScalarProxy, IScalarProjectionClient + { + internal bool Supported { get; set; } = true; + internal int SupportChecks { get; private set; } + internal Func> ProjectionResponse { get; set; } = null!; + internal List ProjectionDispatches { get; } = []; + bool IScalarProjectionClient.SupportsScalarProjection + { + get { SupportChecks++; return Supported; } + } + ValueTask IScalarProjectionClient.ExecuteScalarProjectionAsync( + string sql, string? transactionId, CancellationToken cancellationToken) + { + ProjectionDispatches.Add(new(nameof(IScalarProjectionClient.ExecuteScalarProjectionAsync), sql, transactionId, cancellationToken)); + return new ValueTask(ProjectionResponse()); + } + } +} diff --git a/tests/CSharpDB.Data.Tests/RemoteConnectionPoolingTests.cs b/tests/CSharpDB.Data.Tests/RemoteConnectionPoolingTests.cs new file mode 100644 index 00000000..aca51f19 --- /dev/null +++ b/tests/CSharpDB.Data.Tests/RemoteConnectionPoolingTests.cs @@ -0,0 +1,329 @@ +using System.Data; +using CSharpDB.Client; +using CSharpDB.Testing; + +namespace CSharpDB.Data.Tests; + +[Collection("ConnectionPoolState")] +public sealed class RemoteConnectionPoolingTests : IAsyncLifetime +{ + private GrpcSocketDaemonFixture _host = null!; + private static CancellationToken Ct => TestContext.Current.CancellationToken; + private string ConnectionString => $"Transport=Grpc;Endpoint={_host.CompatibilityEndpoint};Pooling=true;Max Pool Size=1"; + + public ValueTask InitializeAsync() + { + _host = new GrpcSocketDaemonFixture(); + return ValueTask.CompletedTask; + } + + public async ValueTask DisposeAsync() + { + await CSharpDbConnection.ClearPoolAsync(ConnectionString); + await CSharpDbClient.ClearPoolAsync(_host.NativeEndpoint.AbsoluteUri); + await _host.DisposeAsync(); + } + + [Theory] + [InlineData(true, 1)] + [InlineData(false, 3)] + public async Task OpenReadClose_ValidatesOnlyNewPhysicalClients(bool pooling, int expectedOpens) + { + string cs = ConnectionString.Replace("Pooling=true", $"Pooling={pooling}"); + for (int i = 0; i < 3; i++) + { + await using var connection = new CSharpDbConnection(cs); + await connection.OpenAsync(Ct); + Assert.Equal(42, await ScalarAsync(connection)); + } + Assert.Equal(expectedOpens, Count("GetInfo")); + Assert.Equal(3, Count("ExecuteSql")); + Assert.All(_host.Snapshot(), pair => Assert.Equal("HTTP/1.1", pair.Key.Protocol)); + } + + [Fact] + public async Task WarmConfiguration_StillRejectsEmbeddedOptionsAndHonorsCanceledOpen() + { + string cs = ConnectionString; + await using (var first = new CSharpDbConnection(cs)) + await first.OpenAsync(Ct); + + await using var invalid = new CSharpDbConnection(cs) + { + DirectDatabaseOptions = new CSharpDB.Engine.DatabaseOptions(), + }; + await Assert.ThrowsAsync(() => invalid.OpenAsync(Ct)); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + await using var canceled = new CSharpDbConnection(cs); + await Assert.ThrowsAnyAsync(() => canceled.OpenAsync(cancellation.Token)); + Assert.Equal(ConnectionState.Closed, canceled.State); + await using var next = new CSharpDbConnection(cs); + await next.OpenAsync(Ct); + Assert.Equal(42, await ScalarAsync(next)); + Assert.Equal(1, Count("GetInfo")); + } + + [Fact] + public async Task WarmConfiguration_FollowsChangedConnectionStringAndPoolRetirement() + { + string cs = ConnectionString; + await using (var warm = new CSharpDbConnection(cs)) + await warm.OpenAsync(Ct); + await CSharpDbClient.ClearPoolAsync(_host.CompatibilityEndpoint.AbsoluteUri); + await using var connection = new CSharpDbConnection(cs); + await connection.OpenAsync(Ct); + Assert.Equal(42, await ScalarAsync(connection)); + await connection.CloseAsync(); + connection.ConnectionString = $"Transport=Grpc;Grpc Mode=Native;Endpoint={_host.NativeEndpoint};Pooling=true;Max Pool Size=1"; + await connection.OpenAsync(Ct); + Assert.Equal(42, await ScalarAsync(connection)); + Assert.Equal(3, Count("GetInfo")); + Assert.Contains(_host.Snapshot(), pair => pair.Key.Protocol == "HTTP/2" && pair.Key.Method.EndsWith("/ExecuteSql")); + } + + [Fact] + public async Task Capacity_WaitsUntilReturn_AndCancellationDoesNotLeakSlot() + { + await using var owner = new CSharpDbConnection(ConnectionString); + await owner.OpenAsync(Ct); + await using var canceled = new CSharpDbConnection(ConnectionString); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(Ct); + Task canceledOpen = canceled.OpenAsync(cancellation.Token); + Assert.False(canceledOpen.IsCompleted); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => canceledOpen); + Assert.Equal(ConnectionState.Closed, canceled.State); + + await using var waiter = new CSharpDbConnection(ConnectionString); + Task waitingOpen = waiter.OpenAsync(Ct); + Assert.False(waitingOpen.IsCompleted); + Assert.Equal(1, Count("GetInfo")); + await owner.CloseAsync(); + await waitingOpen.WaitAsync(TimeSpan.FromSeconds(10), Ct); + Assert.Equal(42, await ScalarAsync(waiter)); + Assert.Equal(1, Count("GetInfo")); + } + + [Fact] + public async Task Close_RollsBackBeforeReuse_AndOldTransactionCannotAffectNewLease() + { + await using var connection = new CSharpDbConnection(ConnectionString); + await connection.OpenAsync(Ct); + await NonQueryAsync(connection, "CREATE TABLE pool_tx (id INTEGER PRIMARY KEY)"); + var transaction = await connection.BeginTransactionAsync(Ct); + await NonQueryAsync(connection, "INSERT INTO pool_tx VALUES (1)"); + await connection.CloseAsync(); + Assert.Equal(1, Count("RollbackTransaction")); + await connection.OpenAsync(Ct); + Assert.Equal(0L, await ScalarAsync(connection, "SELECT COUNT(*) FROM pool_tx")); + await Assert.ThrowsAsync(() => transaction.CommitAsync(Ct)); + await using var nextTransaction = await connection.BeginTransactionAsync(Ct); + await NonQueryAsync(connection, "INSERT INTO pool_tx VALUES (2)"); + await nextTransaction.CommitAsync(Ct); + Assert.Equal(1L, await ScalarAsync(connection, "SELECT COUNT(*) FROM pool_tx")); + Assert.Equal(1, Count("GetInfo")); + } + + [Fact] + public async Task ClearPool_RetiresActiveLease_AndWakesWaiterIntoNewPool() + { + await using var owner = new CSharpDbConnection(ConnectionString); + await owner.OpenAsync(Ct); + await using var waiter = new CSharpDbConnection(ConnectionString); + Task waitingOpen = waiter.OpenAsync(Ct); + Assert.False(waitingOpen.IsCompleted); + await CSharpDbConnection.ClearPoolAsync(ConnectionString); + await waitingOpen.WaitAsync(TimeSpan.FromSeconds(10), Ct); + Assert.Equal(2, Count("GetInfo")); + Assert.Equal(42, await ScalarAsync(owner)); + await owner.CloseAsync(); + await waiter.CloseAsync(); + await using var next = new CSharpDbConnection(ConnectionString); + await next.OpenAsync(Ct); + Assert.Equal(42, await ScalarAsync(next)); + Assert.Equal(2, Count("GetInfo")); + } + + [Fact] + public async Task ClearAllPools_DiscardsIdleClient() + { + await using var connection = new CSharpDbConnection(ConnectionString); + await connection.OpenAsync(Ct); + await connection.CloseAsync(); + await CSharpDbConnection.ClearAllPoolsAsync(); + await connection.OpenAsync(Ct); + Assert.Equal(2, Count("GetInfo")); + } + + [Fact] + public async Task SdkAndAdoNet_RentTheSamePool_AndBothClearApisRetireIt() + { + var options = new CSharpDbClientOptions + { + Transport = CSharpDbTransport.Grpc, Endpoint = _host.CompatibilityEndpoint.AbsoluteUri, + }; + await using (var sdk = await CSharpDbClient.RentAsync(options, 1, Ct)) + Assert.Null((await sdk.ExecuteSqlAsync("SELECT 42", Ct)).Error); + await using (var ado = new CSharpDbConnection(ConnectionString)) + { + await ado.OpenAsync(Ct); + Assert.Equal(42, await ScalarAsync(ado)); + } + await using (var sdk = await CSharpDbClient.RentAsync(options, 1, Ct)) + Assert.Null((await sdk.ExecuteSqlAsync("SELECT 42", Ct)).Error); + Assert.Equal(1, Count("GetInfo")); + + await CSharpDbClient.ClearPoolAsync(options.Endpoint); + await using (var ado = new CSharpDbConnection(ConnectionString)) await ado.OpenAsync(Ct); + Assert.Equal(2, Count("GetInfo")); + await CSharpDbConnection.ClearPoolAsync(ConnectionString); + await using (var sdk = await CSharpDbClient.RentAsync(options, 1, Ct)) { } + Assert.Equal(3, Count("GetInfo")); + } + + [Fact] + public async Task CanceledRpc_DiscardsClient_WithoutReplayingCommand() + { + await using var connection = new CSharpDbConnection(ConnectionString); + await connection.OpenAsync(Ct); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT 42"; + await Assert.ThrowsAnyAsync(() => command.ExecuteScalarAsync(cancellation.Token)); + await connection.CloseAsync(); + long requestsBefore = Count("ExecuteSql"); + await connection.OpenAsync(Ct); + Assert.Equal(2, Count("GetInfo")); + Assert.Equal(requestsBefore, Count("ExecuteSql")); + Assert.Equal(42, await ScalarAsync(connection)); + } + + [Fact] + public async Task SqlErrorResponse_DoesNotDiscardHealthyChannel() + { + await using var connection = new CSharpDbConnection(ConnectionString); + await connection.OpenAsync(Ct); + await Assert.ThrowsAsync(() => NonQueryAsync(connection, + "CREATE TEMP TABLE requires_transaction (id INTEGER)")); + Assert.Equal(42, await ScalarAsync(connection)); + await connection.CloseAsync(); + await connection.OpenAsync(Ct); + Assert.Equal(1, Count("GetInfo")); + } + + [Fact] + public async Task TransportFailure_ReleasesFailedOpenSlot_AndDiscardsFailedQueryClient() + { + using var handler = new FaultHandler { Fail = true }; + using var httpClient = new HttpClient(handler); + string cs = $"Transport=Grpc;Grpc Mode=Native;Endpoint={_host.NativeEndpoint};Pooling=true;Max Pool Size=1"; + await using var connection = new CSharpDbConnection(cs, httpClient); + await Assert.ThrowsAsync(() => connection.OpenAsync(Ct)); + Assert.Equal(ConnectionState.Closed, connection.State); + handler.Fail = false; + await connection.OpenAsync(Ct).WaitAsync(TimeSpan.FromSeconds(10), Ct); + handler.Fail = true; + await Assert.ThrowsAsync(() => ScalarAsync(connection)); + await connection.CloseAsync(); + handler.Fail = false; + await connection.OpenAsync(Ct); + Assert.Equal(2, Count("GetInfo")); + Assert.Equal(0, Count("ExecuteSql")); + Assert.Equal(42, await ScalarAsync(connection)); + } + + [Fact] + public async Task PoolKeys_IsolateCredentialsRoutesAndCapacity() + { + CSharpDbClientOptions Options(string? key = null, string? route = null, string header = "X-CSharpDB-Api-Key") => new() + { + Transport = CSharpDbTransport.Grpc, Endpoint = _host.CompatibilityEndpoint.AbsoluteUri, + ApiKey = key, ApiKeyHeaderName = header, + RouteContext = route is null ? null : new CSharpDbRouteContext { Keyspace = "tenant", Key = route }, + }; + async Task Rent(CSharpDbClientOptions options, int capacity = 1) + { + await using var session = await CSharpDbClient.RentAsync(options, capacity, Ct); + } + await Rent(Options()); + await Rent(Options()); + Assert.Equal(1, Count("GetInfo")); + await Rent(Options("key-a")); + await Rent(Options("key-b")); + await Rent(Options("key-a", header: "X-Other-Key")); + await Rent(Options(route: "a")); + await Rent(Options(route: "b")); + await Rent(Options(), 2); + Assert.Equal(7, Count("GetInfo")); + await CSharpDbConnection.ClearPoolAsync(ConnectionString); + await Rent(Options("key-a")); + await Rent(Options(route: "a")); + Assert.Equal(9, Count("GetInfo")); + } + + [Fact] + public async Task SuppliedHttpClients_AreIsolated_AndRemainCallerOwnedAfterClear() + { + using var firstClient = new HttpClient(); + using var secondClient = new HttpClient(); + string cs = $"Transport=Grpc;Grpc Mode=Native;Endpoint={_host.NativeEndpoint};Pooling=true;Max Pool Size=1"; + // A warmed plan for the default handler must not capture later callers + // that supply their own HTTP client using the very same string object. + await using (var warm = new CSharpDbConnection(cs)) + await warm.OpenAsync(Ct); + foreach (HttpClient client in new[] { firstClient, firstClient, secondClient }) + { + await using var connection = new CSharpDbConnection(cs, client); + await connection.OpenAsync(Ct); + Assert.Equal(42, await ScalarAsync(connection)); + } + Assert.Equal(3, Count("GetInfo")); + await using (var auto = new CSharpDbConnection(cs.Replace("Grpc Mode=Native", "Grpc Mode=Auto"), firstClient)) + { + await auto.OpenAsync(Ct); + Assert.Equal(42, await ScalarAsync(auto)); + } + Assert.Equal(4, Count("GetInfo")); + await CSharpDbConnection.ClearPoolAsync(cs); + using var response = await firstClient.GetAsync(_host.CompatibilityEndpoint, Ct); + Assert.True((int)response.StatusCode > 0); + } + + [Theory] + [InlineData("Transport=Http;Endpoint=http://localhost;Pooling=true")] + [InlineData("Transport=Grpc;Endpoint=http://localhost;Pooling=true;Max Pool Size=0")] + public async Task Pooling_RejectsUnsupportedTransportAndInvalidCapacity(string connectionString) + { + await using var connection = new CSharpDbConnection(connectionString); + await Assert.ThrowsAnyAsync(() => connection.OpenAsync(Ct)); + Assert.Equal(ConnectionState.Closed, connection.State); + Assert.Equal(0, Count("GetInfo")); + } + + private long Count(string method) => _host.Snapshot() + .Where(pair => pair.Key.Method.EndsWith("/" + method, StringComparison.Ordinal)).Sum(pair => pair.Value); + + private static async Task ScalarAsync(CSharpDbConnection connection, string sql = "SELECT CAST(42 AS INTEGER)") + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + return await command.ExecuteScalarAsync(Ct); + } + + private static async Task NonQueryAsync(CSharpDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + await command.ExecuteNonQueryAsync(Ct); + } + + private sealed class FaultHandler : DelegatingHandler + { + public bool Fail { get; set; } + public FaultHandler() : base(new SocketsHttpHandler()) { } + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Fail ? throw new HttpRequestException("Injected transport failure") : base.SendAsync(request, cancellationToken); + } +} diff --git a/tests/CSharpDB.Data.Tests/RemoteScalarProjectionTests.cs b/tests/CSharpDB.Data.Tests/RemoteScalarProjectionTests.cs new file mode 100644 index 00000000..e484893c --- /dev/null +++ b/tests/CSharpDB.Data.Tests/RemoteScalarProjectionTests.cs @@ -0,0 +1,254 @@ +using System.Data; +using CSharpDB.Client; +using CSharpDB.Testing; + +namespace CSharpDB.Data.Tests; + +// Run unchanged against the existing materialized path and the scalar candidate. +// Real socket transports cover gRPC-Web, native unary, and the persistent stream. +[Collection("ConnectionPoolState")] +public sealed class RemoteScalarProjectionTests +{ + [Theory(Timeout = 45000)] + [InlineData(CSharpDbGrpcMode.Auto, CSharpDbGrpcCallMode.Unary)] + [InlineData(CSharpDbGrpcMode.Native, CSharpDbGrpcCallMode.Unary)] + [InlineData(CSharpDbGrpcMode.Native, CSharpDbGrpcCallMode.Streaming)] + public async Task Scalar_PreservesDeclaredClrTypes_NullAndFirstCellSemantics( + CSharpDbGrpcMode mode, CSharpDbGrpcCallMode calls) + { + var ct = TestContext.Current.CancellationToken; + await RunAsync(mode, calls, async connection => + { + using var command = connection.CreateCommand(); + (string Sql, object Expected)[] cases = + [ + ("SELECT CAST(123 AS INTEGER)", 123), + ("SELECT CAST(2147483648 AS BIGINT)", 2147483648L), + ("SELECT CAST(12345678901234.1234 AS DECIMAL(18,4))", 12345678901234.1234m), + ("SELECT CAST(1 AS BOOLEAN)", true), + ("SELECT CAST(0 AS BOOLEAN)", false), + ("SELECT CAST(NULL AS INTEGER)", DBNull.Value), + ("SELECT CAST(NULL AS BOOLEAN)", DBNull.Value), + ("SELECT NULL", DBNull.Value), + ]; + foreach (var testCase in cases) + { + command.CommandText = testCase.Sql; + object? actual = await command.ExecuteScalarAsync(ct); + Assert.Equal(testCase.Expected.GetType(), actual?.GetType()); + Assert.Equal(testCase.Expected, actual); + } + + command.CommandText = "CREATE TABLE scalar_cells (id INTEGER PRIMARY KEY, value INTEGER, ignored TEXT)"; + Assert.Null(await command.ExecuteScalarAsync(ct)); + command.CommandText = "INSERT INTO scalar_cells VALUES (2, 222, 'second'); INSERT INTO scalar_cells VALUES (1, 111, 'first')"; + Assert.Null(await command.ExecuteScalarAsync(ct)); + command.CommandText = "SELECT value, ignored FROM scalar_cells ORDER BY id"; + Assert.Equal(111, Assert.IsType(await command.ExecuteScalarAsync(ct))); + command.CommandText = "SELECT value, ignored FROM scalar_cells WHERE id = 99"; + Assert.Null(await command.ExecuteScalarAsync(ct)); + command.CommandText = "SELECT NULL, ignored FROM scalar_cells ORDER BY id"; + Assert.Same(DBNull.Value, await command.ExecuteScalarAsync(ct)); + }, ct); + } + + [Theory(Timeout = 45000)] + [InlineData(CSharpDbGrpcMode.Auto, CSharpDbGrpcCallMode.Unary)] + [InlineData(CSharpDbGrpcMode.Native, CSharpDbGrpcCallMode.Unary)] + [InlineData(CSharpDbGrpcMode.Native, CSharpDbGrpcCallMode.Streaming)] + public async Task Scalar_ReusedCommandAndSql_UseCurrentResponseMetadataAfterSchemaChanges( + CSharpDbGrpcMode mode, CSharpDbGrpcCallMode calls) + { + var ct = TestContext.Current.CancellationToken; + await RunAsync(mode, calls, async connection => + { + using var ddl = connection.CreateCommand(); + ddl.CommandText = "CREATE TABLE scalar_metadata (id INTEGER PRIMARY KEY, value INTEGER); INSERT INTO scalar_metadata VALUES (1, 7)"; + await ddl.ExecuteNonQueryAsync(ct); + using var scalar = connection.CreateCommand(); + scalar.CommandText = "SELECT value FROM scalar_metadata WHERE id = 1"; + scalar.Prepare(); + Assert.Equal(7, Assert.IsType(await scalar.ExecuteScalarAsync(ct))); + + // Keep the scalar command and SQL unchanged. Even equal values must + // change CLR type when their declared SQL type changes. + ddl.CommandText = "DROP TABLE scalar_metadata; CREATE TABLE scalar_metadata (id INTEGER PRIMARY KEY, value BIGINT); INSERT INTO scalar_metadata VALUES (1, 7)"; + await ddl.ExecuteNonQueryAsync(ct); + Assert.Equal(7L, Assert.IsType(await scalar.ExecuteScalarAsync(ct))); + ddl.CommandText = "DROP TABLE scalar_metadata; CREATE TABLE scalar_metadata (id INTEGER PRIMARY KEY, value BOOLEAN); INSERT INTO scalar_metadata VALUES (1, 1)"; + await ddl.ExecuteNonQueryAsync(ct); + Assert.True(Assert.IsType(await scalar.ExecuteScalarAsync(ct))); + }, ct); + } + + [Theory(Timeout = 45000)] + [InlineData(CSharpDbGrpcMode.Auto, CSharpDbGrpcCallMode.Unary)] + [InlineData(CSharpDbGrpcMode.Native, CSharpDbGrpcCallMode.Unary)] + [InlineData(CSharpDbGrpcMode.Native, CSharpDbGrpcCallMode.Streaming)] + public async Task Scalar_BindsChangingParameters_AndLeavesQuotedAndCommentedPlaceholdersAlone( + CSharpDbGrpcMode mode, CSharpDbGrpcCallMode calls) + { + var ct = TestContext.Current.CancellationToken; + await RunAsync(mode, calls, async connection => + { + foreach (bool prepare in new[] { false, true }) + { + using var command = connection.CreateCommand(); + command.CommandText = "SELECT CAST(@value AS INTEGER) /* @missing */"; + var value = command.Parameters.AddWithValue("@value", 7); + if (prepare) command.Prepare(); + Assert.Equal(7, Assert.IsType(await command.ExecuteScalarAsync(ct))); + value.Value = 19; + Assert.Equal(19, Assert.IsType(await command.ExecuteScalarAsync(ct))); + value.Value = DBNull.Value; + Assert.Same(DBNull.Value, await command.ExecuteScalarAsync(ct)); + + command.CommandText = "SELECT @value AS \"@missing\" WHERE @value = 'expected' /* @missing */"; + value.Value = "' OR 1 = 1 --"; + if (prepare) command.Prepare(); + Assert.Null(await command.ExecuteScalarAsync(ct)); + command.CommandText = "SELECT @value AS \"@missing\" /* @missing */"; + if (prepare) command.Prepare(); + Assert.Equal("' OR 1 = 1 --", await command.ExecuteScalarAsync(ct)); + + command.CommandText = "SELECT @amount"; + command.Parameters.Clear(); + command.Parameters.Add(new CSharpDbParameter("@amount", 12345678901234.1234m) + { + DbType = DbType.Decimal, Precision = 18, Scale = 4, + }); + if (prepare) command.Prepare(); + Assert.Equal(12345678901234.1234m, + Assert.IsType(await command.ExecuteScalarAsync(ct))); + } + }, ct); + } + + [Theory(Timeout = 45000)] + [InlineData(CSharpDbGrpcMode.Auto, CSharpDbGrpcCallMode.Unary)] + [InlineData(CSharpDbGrpcMode.Native, CSharpDbGrpcCallMode.Unary)] + [InlineData(CSharpDbGrpcMode.Native, CSharpDbGrpcCallMode.Streaming)] + public async Task Scalar_DispatchesThroughActiveTransactions_AndTransactionControlCommands( + CSharpDbGrpcMode mode, CSharpDbGrpcCallMode calls) + { + var ct = TestContext.Current.CancellationToken; + await RunAsync(mode, calls, async connection => + { + using var command = connection.CreateCommand(); + command.CommandText = "CREATE TABLE scalar_tx (id INTEGER PRIMARY KEY, value INTEGER)"; + await command.ExecuteNonQueryAsync(ct); + await using (var transaction = await connection.BeginTransactionAsync(ct)) + { + command.Transaction = transaction; + command.CommandText = "INSERT INTO scalar_tx VALUES (1, 123)"; + Assert.Null(await command.ExecuteScalarAsync(ct)); + command.CommandText = "SELECT value FROM scalar_tx WHERE id = 1"; + Assert.Equal(123, Assert.IsType(await command.ExecuteScalarAsync(ct))); + await transaction.CommitAsync(ct); + } + command.Transaction = null; + await using (var transaction = await connection.BeginTransactionAsync(ct)) + { + command.Transaction = transaction; + command.CommandText = "INSERT INTO scalar_tx VALUES (2, 456)"; + Assert.Null(await command.ExecuteScalarAsync(ct)); + command.CommandText = "SELECT value FROM scalar_tx WHERE id = 2"; + Assert.Equal(456, Assert.IsType(await command.ExecuteScalarAsync(ct))); + await transaction.RollbackAsync(ct); + } + command.Transaction = null; + command.CommandText = "SELECT COUNT(*) FROM scalar_tx"; + Assert.Equal(1L, Assert.IsType(await command.ExecuteScalarAsync(ct))); + + command.CommandText = "BEGIN TRANSACTION;"; + Assert.Null(await command.ExecuteScalarAsync(ct)); + command.CommandText = "INSERT INTO scalar_tx VALUES (3, 789)"; + Assert.Null(await command.ExecuteScalarAsync(ct)); + command.CommandText = "ROLLBACK;"; + Assert.Null(await command.ExecuteScalarAsync(ct)); + command.CommandText = "SELECT COUNT(*) FROM scalar_tx"; + Assert.Equal(1L, Assert.IsType(await command.ExecuteScalarAsync(ct))); + }, ct); + } + + [Theory(Timeout = 45000)] + [InlineData(CSharpDbGrpcMode.Auto, CSharpDbGrpcCallMode.Unary)] + [InlineData(CSharpDbGrpcMode.Native, CSharpDbGrpcCallMode.Unary)] + [InlineData(CSharpDbGrpcMode.Native, CSharpDbGrpcCallMode.Streaming)] + public async Task Scalar_ExecutesWholeScriptsOnce_AndReturnsTheLastStatementsResult( + CSharpDbGrpcMode mode, CSharpDbGrpcCallMode calls) + { + var ct = TestContext.Current.CancellationToken; + await RunAsync(mode, calls, async connection => + { + using var command = connection.CreateCommand(); + command.CommandText = "CREATE TABLE scalar_script (id INTEGER PRIMARY KEY, value INTEGER)"; + await command.ExecuteNonQueryAsync(ct); + command.CommandText = "INSERT INTO scalar_script VALUES (1, 10); SELECT value FROM scalar_script WHERE id = 1; INSERT INTO scalar_script VALUES (2, 20); SELECT COUNT(*) FROM scalar_script"; + Assert.Equal(2L, Assert.IsType(await command.ExecuteScalarAsync(ct))); + command.CommandText = "SELECT COUNT(*) FROM scalar_script; INSERT INTO scalar_script VALUES (3, 30)"; + Assert.Null(await command.ExecuteScalarAsync(ct)); + command.CommandText = "SELECT COUNT(*) FROM scalar_script"; + Assert.Equal(3L, Assert.IsType(await command.ExecuteScalarAsync(ct))); + + // An earlier SELECT must not hide a later error. Statements before + // the error retain existing autocommit effects; later ones do not run. + command.CommandText = "INSERT INTO scalar_script VALUES (4, 40); SELECT value FROM scalar_script WHERE id = 1; SELECT value FROM scalar_missing; INSERT INTO scalar_script VALUES (5, 50)"; + var error = await Assert.ThrowsAsync(() => command.ExecuteScalarAsync(ct)); + Assert.Contains("scalar_missing", error.Message, StringComparison.OrdinalIgnoreCase); + command.CommandText = "SELECT COUNT(*) FROM scalar_script WHERE id = 4"; + Assert.Equal(1L, Assert.IsType(await command.ExecuteScalarAsync(ct))); + command.CommandText = "SELECT COUNT(*) FROM scalar_script WHERE id = 5"; + Assert.Equal(0L, Assert.IsType(await command.ExecuteScalarAsync(ct))); + }, ct); + } + + [Theory(Timeout = 45000)] + [InlineData(CSharpDbGrpcMode.Auto, CSharpDbGrpcCallMode.Unary)] + [InlineData(CSharpDbGrpcMode.Native, CSharpDbGrpcCallMode.Unary)] + [InlineData(CSharpDbGrpcMode.Native, CSharpDbGrpcCallMode.Streaming)] + public async Task Scalar_PreservesSqlErrorsAndCanceledDispatch_WithoutReplayingWrites( + CSharpDbGrpcMode mode, CSharpDbGrpcCallMode calls) + { + var ct = TestContext.Current.CancellationToken; + await RunAsync(mode, calls, async connection => + { + using var command = connection.CreateCommand(); + command.CommandText = "SELECT value FROM scalar_missing"; + var error = await Assert.ThrowsAsync(() => command.ExecuteScalarAsync(ct)); + Assert.Contains("scalar_missing", error.Message, StringComparison.OrdinalIgnoreCase); + command.CommandText = "SELECT CAST(42 AS INTEGER)"; + Assert.Equal(42, Assert.IsType(await command.ExecuteScalarAsync(ct))); + + command.CommandText = "CREATE TABLE scalar_canceled (id INTEGER PRIMARY KEY)"; + await command.ExecuteNonQueryAsync(ct); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(ct); + cancellation.Cancel(); + command.CommandText = "INSERT INTO scalar_canceled VALUES (1); SELECT COUNT(*) FROM scalar_canceled"; + await Assert.ThrowsAnyAsync(() => command.ExecuteScalarAsync(cancellation.Token)); + await connection.CloseAsync(); + await connection.OpenAsync(ct); + command.CommandText = "SELECT COUNT(*) FROM scalar_canceled"; + Assert.Equal(0L, Assert.IsType(await command.ExecuteScalarAsync(ct))); + }, ct); + } + + private static async Task RunAsync( + CSharpDbGrpcMode mode, CSharpDbGrpcCallMode calls, + Func test, CancellationToken cancellationToken) + { + await using var host = new GrpcSocketDaemonFixture(); + string endpoint = host.EndpointFor(mode).AbsoluteUri; + string connectionString = $"Transport=Grpc;Endpoint={endpoint};Grpc Mode={mode};Grpc Call Mode={calls};Pooling=true;Max Pool Size=1"; + try + { + await using var connection = new CSharpDbConnection(connectionString); + await connection.OpenAsync(cancellationToken); + await test(connection); + } + finally + { + await CSharpDbClient.ClearPoolAsync(endpoint); + } + } +} diff --git a/tests/CSharpDB.Data.Tests/RemoteScalarProjectionUnitTests.cs b/tests/CSharpDB.Data.Tests/RemoteScalarProjectionUnitTests.cs new file mode 100644 index 00000000..9d9a8b56 --- /dev/null +++ b/tests/CSharpDB.Data.Tests/RemoteScalarProjectionUnitTests.cs @@ -0,0 +1,579 @@ +using System.Reflection; +using CSharpDB.Client; +using CSharpDB.Client.Models; +using CSharpDB.Execution; +using CSharpDB.Primitives; +using ClientColumn = CSharpDB.Client.Models.ColumnDefinition; +using ClientDbType = CSharpDB.Client.Models.DbType; +using ClientSqlType = CSharpDB.Client.Models.SqlTypeDescriptor; +using ClientSqlKind = CSharpDB.Client.Models.SqlTypeKind; + +namespace CSharpDB.Data.Tests; + +// Compare remote scalar conversion with the materialized result's existing semantics. +public sealed class RemoteScalarProjectionUnitTests +{ + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(6)] + [InlineData(7)] + [InlineData(8)] + [InlineData(9)] + [InlineData(10)] + [InlineData(11)] + [InlineData(12)] + public async Task CompleteSingleCellMetadata_PreservesExistingConversions(int shape) + { + var ct = TestContext.Current.CancellationToken; + (ClientColumn Column, object? Value, object Expected) conversion = shape switch + { + 0 => (Column(ClientDbType.Integer, ClientSqlKind.Integer), 123L, 123), + 1 => (Column(ClientDbType.Integer, ClientSqlKind.BigInt), 123L, 123L), + 2 => (Column(ClientDbType.Integer, ClientSqlKind.SmallInt), 123L, (short)123), + 3 => (Column(ClientDbType.Integer, ClientSqlKind.TinyInt), 123L, (byte)123), + 4 => (Column(ClientDbType.Integer, ClientSqlKind.Boolean), 1L, true), + 5 => (Column(ClientDbType.Integer, ClientSqlKind.Boolean), false, false), + 6 => (Column(ClientDbType.Decimal, ClientSqlKind.Decimal), 12345678901234.1234m, 12345678901234.1234m), + 7 => (Column(ClientDbType.Text, ClientSqlKind.Text), "text", "text"), + 8 => (Column(ClientDbType.Text, ClientSqlKind.Uuid), "e356efad-5c0f-4fb1-acfe-e7e109a324b4", Guid.Parse("e356efad-5c0f-4fb1-acfe-e7e109a324b4")), + 9 => (Column(ClientDbType.Text, ClientSqlKind.Date), "2026-10-14", new DateOnly(2026, 10, 14)), + 10 => (Column(ClientDbType.Integer, ClientSqlKind.Integer), null, DBNull.Value), + 11 => (Column(ClientDbType.Integer, ClientSqlKind.Boolean), DBNull.Value, DBNull.Value), + // Legacy physical metadata intentionally uses Int64, unlike INTEGER. + _ => (new ClientColumn { Name = "value", Type = ClientDbType.Integer }, 123L, 123L), + }; + var (column, value, expected) = conversion; + var response = Query([value], column); + var (client, proxy) = Client(() => Task.FromResult(response)); + await using var session = new RemoteDatabaseSession(client); + object? actual = await session.ExecuteScalarAsync("SELECT unchanged", ct); + Assert.Equal(expected.GetType(), actual?.GetType()); + Assert.Equal(expected, actual); + Assert.Single(proxy.Dispatches); + Assert.Equal("SELECT unchanged", proxy.Dispatches[0].Sql); + Assert.Equal(ct, proxy.Dispatches[0].Token); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(6)] + public async Task OlderServerMetadata_UsesTheSameLogicalAndInferredFallback(int shape) + { + var ct = TestContext.Current.CancellationToken; + (string? Label, object? Input, object Expected) conversion = shape switch + { + 0 => (null, 42L, 42L), + 1 => ("INTEGER", 42L, 42), + 2 => ("BIGINT", 42L, 42L), + 3 => ("BOOLEAN", 1L, true), + 4 => ("INTEGER", null, DBNull.Value), + 5 => ("provider_specific_label", "value", "value"), + _ => ("DECIMAL(10,4)", 0.1234m, 0.1234m), + }; + var (label, input, expected) = conversion; + var response = new SqlExecutionResult + { + IsQuery = true, ColumnNames = ["value"], + ColumnTypes = label is null ? null : [label], + ColumnNullability = [true], Rows = [[input]], + }; + var (client, proxy) = Client(() => Task.FromResult(response)); + await using var session = new RemoteDatabaseSession(client); + object? actual = await session.ExecuteScalarAsync("SELECT unchanged", ct); + Assert.Equal(expected.GetType(), actual?.GetType()); + Assert.Equal(expected, actual); + Assert.Single(proxy.Dispatches); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(6)] + public async Task MalformedOrUnusedValuesAndMetadata_RetainMaterializedFailures(int shape) + { + var ct = TestContext.Current.CancellationToken; + SqlExecutionResult response = shape switch + { + // The second selected cell is unused by ExecuteScalar, but the + // existing result materializer still converts it and reports failure. + 0 => new() + { + IsQuery = true, ColumnNames = ["value", "ignored"], + Columns = [Column(ClientDbType.Integer, ClientSqlKind.Integer), + new ClientColumn { Name = "ignored", Type = ClientDbType.Text }], + Rows = [[42L, new ExplodingValue()]], + }, + // The same holds for rows after the selected first row. + 1 => new() + { + IsQuery = true, ColumnNames = ["value"], + Columns = [Column(ClientDbType.Integer, ClientSqlKind.Integer)], + Rows = [[42L], [new ExplodingValue()]], + }, + // Unused column descriptors are validated while mapping the schema. + 2 => new() + { + IsQuery = true, ColumnNames = ["value", "ignored"], + Columns = [Column(ClientDbType.Integer, ClientSqlKind.Integer), + Column(ClientDbType.Text, (ClientSqlKind)255, "ignored")], + Rows = [[42L, "ignored"]], + }, + // A row with no first value is malformed, rather than an empty result. + 3 => Query([], Column(ClientDbType.Integer, ClientSqlKind.Integer)), + // A surplus physical cell must not bypass its existing conversion. + 4 => Query([42L, new ExplodingValue()], Column(ClientDbType.Integer, ClientSqlKind.Integer)), + 5 => Query([2147483648L], Column(ClientDbType.Integer, ClientSqlKind.Integer)), + _ => Query([12345678901234567890.1234m], Column(ClientDbType.Decimal, ClientSqlKind.Decimal)), + }; + var (baselineClient, baselineProxy) = Client(() => Task.FromResult(response)); + await using var baseline = new RemoteDatabaseSession(baselineClient); + Exception? expected = await Record.ExceptionAsync(() => MaterializedScalarAsync(baseline, "SELECT unchanged", ct)); + Assert.NotNull(expected); + + var (client, proxy) = Client(() => Task.FromResult(response)); + await using var session = new RemoteDatabaseSession(client); + Exception? actual = await Record.ExceptionAsync(async () => { await session.ExecuteScalarAsync("SELECT unchanged", ct); }); + Assert.NotNull(actual); + Assert.Equal(expected.GetType(), actual.GetType()); + Assert.Equal(expected.Message, actual.Message); + Assert.Single(baselineProxy.Dispatches); + Assert.Single(proxy.Dispatches); + } + + [Theory] + [InlineData(0, true)] + [InlineData(1, true)] + [InlineData(2, false)] + [InlineData(3, false)] + public async Task CancellationAfterCompletedResponse_PreservesQueryBoundaryWithoutReplay( + int shape, bool throwsCancellation) + { + var ct = TestContext.Current.CancellationToken; + using var canceledAtResponse = CancellationTokenSource.CreateLinkedTokenSource(ct); + SqlExecutionResult response = shape switch + { + 0 => Query([42L], Column(ClientDbType.Integer, ClientSqlKind.Integer)), + 1 => new() { IsQuery = true, ColumnNames = ["value"], Columns = [Column(ClientDbType.Integer, ClientSqlKind.Integer)], Rows = [] }, + 2 => new() { IsQuery = false, RowsAffected = 1 }, + _ => new() { IsQuery = true, ColumnNames = [], Columns = [], Rows = [] }, + }; + var (client, proxy) = Client(() => + { + // Deterministic: transport reports a completed response, then the + // ADO conversion boundary observes cancellation. There is no timer. + canceledAtResponse.Cancel(); + return Task.FromResult(response); + }); + await using var session = new RemoteDatabaseSession(client); + const string sql = "INSERT INTO exact_script VALUES (1); SELECT value FROM exact_script"; + if (throwsCancellation) + { + var error = await Assert.ThrowsAnyAsync(async () => + { + await session.ExecuteScalarAsync(sql, canceledAtResponse.Token); + }); + Assert.Equal(canceledAtResponse.Token, error.CancellationToken); + } + else + { + // Existing ExecuteScalar does not advance a row for DML or a + // zero-column response, so it has no post-response cancellation check. + Assert.Null(await session.ExecuteScalarAsync(sql, canceledAtResponse.Token)); + } + Assert.Single(proxy.Dispatches); + Assert.Equal(sql, proxy.Dispatches[0].Sql); + } + + [Fact] + public async Task ActiveTransaction_UsesTheSameSingleDispatchAndRetainsExactSql() + { + var ct = TestContext.Current.CancellationToken; + var (client, proxy) = Client(() => Task.FromResult(Query([42L], Column(ClientDbType.Integer, ClientSqlKind.Integer)))); + var session = new RemoteDatabaseSession(client); + const string sql = "INSERT INTO exact_script VALUES (1); SELECT value FROM exact_script"; + await session.BeginTransactionAsync(ct); + Assert.Equal(42, Assert.IsType(await session.ExecuteScalarAsync(sql, ct))); + Assert.Equal(nameof(ICSharpDbClient.ExecuteInTransactionAsync), proxy.Dispatches[0].Method); + Assert.Equal("scalar-transaction", proxy.Dispatches[0].TransactionId); + Assert.Equal(sql, proxy.Dispatches[0].Sql); + Assert.Equal(ct, proxy.Dispatches[0].Token); + await session.CommitAsync(ct); + Assert.Equal(42, Assert.IsType(await session.ExecuteScalarAsync(sql, ct))); + Assert.Equal(2, proxy.Dispatches.Count); + Assert.Equal(nameof(ICSharpDbClient.ExecuteSqlAsync), proxy.Dispatches[1].Method); + Assert.Null(proxy.Dispatches[1].TransactionId); + Assert.Equal(sql, proxy.Dispatches[1].Sql); + await session.DisposeAsync(); + Assert.Equal(1, proxy.Begins); + Assert.Equal(1, proxy.Commits); + Assert.Equal(0, proxy.Rollbacks); + Assert.Equal(1, proxy.Disposals); + } + + [Theory] + [InlineData("result", false)] + [InlineData("result", true)] + [InlineData("synchronous", false)] + [InlineData("synchronous", true)] + [InlineData("asynchronous", false)] + [InlineData("asynchronous", true)] + public async Task Errors_DoNotReplayOrChangeTransactionDispatch(string failure, bool activeTransaction) + { + var ct = TestContext.Current.CancellationToken; + var injected = new HttpRequestException("response lost after dispatch"); + var (client, proxy) = Client(() => failure switch + { + "result" => Task.FromResult(new SqlExecutionResult { Error = "injected SQL failure", ErrorCode = ErrorCode.Busy }), + "synchronous" => throw injected, + _ => Task.FromException(injected), + }); + var session = new RemoteDatabaseSession(client); + if (activeTransaction) await session.BeginTransactionAsync(ct); + const string sql = "INSERT INTO exact_script VALUES (1); SELECT value FROM exact_script"; + Exception? error = await Record.ExceptionAsync(async () => { await session.ExecuteScalarAsync(sql, ct); }); + if (failure == "result") + { + var databaseError = Assert.IsType(error); + Assert.Equal("injected SQL failure", databaseError.Message); + // The current session maps response.Error to Unknown. Preserve this + // existing behavior here; ErrorCode transport fidelity is separate. + Assert.Equal(ErrorCode.Unknown, databaseError.Code); + } + else Assert.Same(injected, error); + Assert.Single(proxy.Dispatches); + Assert.Equal(sql, proxy.Dispatches[0].Sql); + Assert.Equal(activeTransaction ? "scalar-transaction" : null, proxy.Dispatches[0].TransactionId); + Assert.Equal(activeTransaction ? nameof(ICSharpDbClient.ExecuteInTransactionAsync) : nameof(ICSharpDbClient.ExecuteSqlAsync), proxy.Dispatches[0].Method); + await session.DisposeAsync(); + Assert.Single(proxy.Dispatches); + Assert.Equal(activeTransaction ? 1 : 0, proxy.Rollbacks); + Assert.Equal(1, proxy.Disposals); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(6)] + [InlineData(7)] + [InlineData(8)] + [InlineData(9)] + [InlineData(10)] + [InlineData(11)] + [InlineData(12)] + [InlineData(13)] + [InlineData(14)] + [InlineData(15)] + public async Task InvalidDeclaredFacets_PreserveValidationForNullAndCanceledResponses(int shape) + { + ClientSqlType type = shape switch + { + 0 => new() { Kind = ClientSqlKind.Integer, Length = 1 }, + 1 => new() { Kind = ClientSqlKind.VarChar, Length = 0 }, + 2 => new() { Kind = ClientSqlKind.VarBinary, Length = -1 }, + 3 => new() { Kind = ClientSqlKind.Integer, Precision = 1 }, + 4 => new() { Kind = ClientSqlKind.Integer, Scale = 0 }, + 5 => new() { Kind = ClientSqlKind.Decimal, Precision = 0 }, + 6 => new() { Kind = ClientSqlKind.Decimal, Precision = 19 }, + 7 => new() { Kind = ClientSqlKind.Decimal, Scale = 0 }, + 8 => new() { Kind = ClientSqlKind.Decimal, Precision = 3, Scale = -1 }, + 9 => new() { Kind = ClientSqlKind.Decimal, Precision = 3, Scale = 4 }, + 10 => new() { Kind = ClientSqlKind.Integer, FractionalSecondsPrecision = 0 }, + 11 => new() { Kind = ClientSqlKind.Time, FractionalSecondsPrecision = -1 }, + 12 => new() { Kind = ClientSqlKind.Timestamp, FractionalSecondsPrecision = 8 }, + 13 => new() { Kind = (ClientSqlKind)255 }, + // Multiple invalid facets retain Create's first failing argument. + 14 => new() { Kind = ClientSqlKind.VarChar, Length = 0, Precision = 1 }, + _ => new() { Kind = ClientSqlKind.Decimal, Length = 1, Precision = 0 }, + }; + var column = new ClientColumn { Name = "value", Type = ClientDbType.Integer, DeclaredType = type }; + foreach (bool nullValue in new[] { false, true }) + foreach (bool cancelAtResponse in new[] { false, true }) + { + // Validation must run even when CLR conversion would return DBNull + // immediately, and before the query's cancellation boundary. + var response = Query([nullValue ? null : 42L], column); + Exception error = await AssertMaterializedFailureAsync(response, cancelAtResponse); + Assert.IsNotType(error); + Assert.True(error is ArgumentException or InvalidOperationException); + } + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public async Task SingleCellFailures_PreserveValueMetadataCancellationAndClrOrder(int shape) + { + ClientColumn invalid = new() + { + Name = "value", Type = ClientDbType.Integer, + DeclaredType = new() { Kind = ClientSqlKind.Integer, Length = 1 }, + }; + SqlExecutionResult response = shape switch + { + // Raw cell conversion runs before metadata and cancellation. + 0 => Query([new ExplodingValue()], invalid), + // Facet validation runs before cancellation and checked Int32 conversion. + 1 => Query([2147483648L], invalid), + // With valid metadata, cancellation wins over checked CLR conversion. + 2 or 3 => Query([2147483648L], Column(ClientDbType.Integer, ClientSqlKind.Integer)), + // A SQL error wins before any cell or metadata conversion. + _ => new() + { + IsQuery = true, ColumnNames = ["value"], Columns = [invalid], + Rows = [[new ExplodingValue()]], Error = "SQL failed before projection", + }, + }; + Exception error = await AssertMaterializedFailureAsync(response, cancelAtResponse: shape != 3); + switch (shape) + { + case 0: + Assert.IsType(error); + Assert.Equal("unused cell conversion failed", error.Message); + break; + case 1: + Assert.Equal("length", Assert.IsType(error).ParamName); + break; + case 2: + Assert.IsAssignableFrom(error); + break; + case 3: + Assert.IsType(error); + break; + default: + Assert.Equal("SQL failed before projection", Assert.IsType(error).Message); + break; + } + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(6)] + [InlineData(7)] + [InlineData(8)] + [InlineData(9)] + [InlineData(10)] + [InlineData(11)] + [InlineData(12)] + [InlineData(13)] + [InlineData(14)] + [InlineData(15)] + [InlineData(16)] + [InlineData(17)] + [InlineData(18)] + [InlineData(19)] + public async Task RemainingLogicalKindsAndValidFacets_MatchMaterializedProjection(int shape) + { + byte[] bytes = [0x80]; + var bits = new SqlBitString(bytes, 1); + var time = new TimeOnly(12, 34, 56).Add(TimeSpan.FromTicks(1234567)); + var timestamp = new DateTime(2026, 10, 14, 12, 34, 56).AddTicks(1234567); + var offset = new DateTimeOffset(timestamp, TimeSpan.FromHours(-7)); + (ClientSqlType Type, object Input, object Expected) conversion = shape switch + { + 0 => (new() { Kind = ClientSqlKind.Real }, 1.25d, 1.25d), + 1 => (new() { Kind = ClientSqlKind.Double }, 1.25d, 1.25d), + 2 => (new() { Kind = ClientSqlKind.Char, Length = 1 }, "x", "x"), + 3 => (new() { Kind = ClientSqlKind.VarChar, Length = int.MaxValue }, "text", "text"), + 4 => (new() { Kind = ClientSqlKind.Binary, Length = 1 }, bytes, bytes), + 5 => (new() { Kind = ClientSqlKind.VarBinary, Length = int.MaxValue }, bytes, bytes), + 6 => (new() { Kind = ClientSqlKind.Blob }, bytes, bytes), + 7 => (new() { Kind = ClientSqlKind.Time, FractionalSecondsPrecision = 7 }, time, time), + 8 => (new() { Kind = ClientSqlKind.Timestamp, FractionalSecondsPrecision = 7 }, timestamp, timestamp), + 9 => (new() { Kind = ClientSqlKind.TimestampWithTimeZone, FractionalSecondsPrecision = 7 }, offset, offset), + 10 => (new() { Kind = ClientSqlKind.IntervalYearToMonth }, "1-2", "1-2"), + 11 => (new() { Kind = ClientSqlKind.IntervalDayToSecond, FractionalSecondsPrecision = 7 }, "1.02:03:04.1234567", TimeSpan.Parse("1.02:03:04.1234567", System.Globalization.CultureInfo.InvariantCulture)), + 12 => (new() { Kind = ClientSqlKind.Json }, "{\"value\":42}", "{\"value\":42}"), + 13 => (new() { Kind = ClientSqlKind.Xml }, "42", "42"), + 14 => (new() { Kind = ClientSqlKind.Bit, Length = 1 }, bits, bits), + 15 => (new() { Kind = ClientSqlKind.VarBit, Length = int.MaxValue }, bits, bits), + 16 => (new() { Kind = ClientSqlKind.Decimal, Precision = 1, Scale = 0 }, 1m, 1m), + 17 => (new() { Kind = ClientSqlKind.Decimal, Precision = 18, Scale = 18 }, 0.123456789012345678m, 0.123456789012345678m), + 18 => (new() { Kind = ClientSqlKind.Time, FractionalSecondsPrecision = 0 }, "12:34:56", new TimeOnly(12, 34, 56)), + _ => (new() { Kind = ClientSqlKind.IntervalDayToSecond, FractionalSecondsPrecision = 0 }, "02:03:04", new TimeSpan(2, 3, 4)), + }; + var column = new ClientColumn + { + Name = "value", Type = conversion.Type.StorageType, DeclaredType = conversion.Type, + }; + foreach (bool nullValue in new[] { false, true }) + { + object expected = nullValue ? DBNull.Value : conversion.Expected; + object? actual = await AssertMaterializedValueAsync(Query([nullValue ? null : conversion.Input], column)); + Assert.Equal(expected.GetType(), actual?.GetType()); + Assert.Equal(expected, actual); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task UnknownPhysicalType_RetainsLegacyMappingBehavior(bool hasDeclaredType) + { + var column = new ClientColumn + { + Name = "value", Type = (ClientDbType)255, + DeclaredType = hasDeclaredType ? new() { Kind = ClientSqlKind.Integer } : null, + }; + object? actual = await AssertMaterializedValueAsync(Query([42L], column)); + if (hasDeclaredType) Assert.Equal(42, Assert.IsType(actual)); + else Assert.Equal(42L, Assert.IsType(actual)); + } + + private static async Task AssertMaterializedValueAsync(SqlExecutionResult response) + { + const string sql = "SELECT same_sql_and_value"; + var ct = TestContext.Current.CancellationToken; + var (baselineClient, baselineProxy) = Client(() => Task.FromResult(response)); + await using var baseline = new RemoteDatabaseSession(baselineClient); + object? expected = await MaterializedScalarAsync(baseline, sql, ct); + var (client, proxy) = Client(() => Task.FromResult(response)); + await using var session = new RemoteDatabaseSession(client); + object? actual = await session.ExecuteScalarAsync(sql, ct); + Assert.Equal(expected?.GetType(), actual?.GetType()); + Assert.Equal(expected, actual); + AssertSingleSqlDispatch(baselineProxy, sql, ct); + AssertSingleSqlDispatch(proxy, sql, ct); + return actual; + } + + private static async Task AssertMaterializedFailureAsync( + SqlExecutionResult response, bool cancelAtResponse) + { + const string sql = "INSERT INTO exact_script VALUES (1); SELECT value FROM exact_script"; + Exception expected = await ExecuteAsync(materialize: true); + Exception actual = await ExecuteAsync(materialize: false); + Assert.Equal(expected.GetType(), actual.GetType()); + Assert.Equal(expected.Message, actual.Message); + if (expected is ArgumentException argument) + Assert.Equal(argument.ParamName, Assert.IsAssignableFrom(actual).ParamName); + if (expected is ArgumentOutOfRangeException range) + Assert.Equal(range.ActualValue, Assert.IsType(actual).ActualValue); + return actual; + + async Task ExecuteAsync(bool materialize) + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var (client, proxy) = Client(() => + { + if (cancelAtResponse) cancellation.Cancel(); + return Task.FromResult(response); + }); + await using var session = new RemoteDatabaseSession(client); + Exception? error = await Record.ExceptionAsync(async () => + { + if (materialize) await MaterializedScalarAsync(session, sql, cancellation.Token); + else await session.ExecuteScalarAsync(sql, cancellation.Token); + }); + Assert.NotNull(error); + if (error is OperationCanceledException canceled) + Assert.Equal(cancellation.Token, canceled.CancellationToken); + AssertSingleSqlDispatch(proxy, sql, cancellation.Token); + return error; + } + } + + private static void AssertSingleSqlDispatch(ScalarClientProxy proxy, string sql, CancellationToken token) + { + Dispatch dispatch = Assert.Single(proxy.Dispatches); + Assert.Equal(nameof(ICSharpDbClient.ExecuteSqlAsync), dispatch.Method); + Assert.Equal(sql, dispatch.Sql); + Assert.Null(dispatch.TransactionId); + Assert.Equal(token, dispatch.Token); + } + + private static ClientColumn Column(ClientDbType physical, ClientSqlKind kind, string name = "value") + => new() { Name = name, Type = physical, DeclaredType = new ClientSqlType { Kind = kind } }; + + private static SqlExecutionResult Query(object?[] row, ClientColumn column) + => new() { IsQuery = true, ColumnNames = [column.Name], Columns = [column], Rows = [row] }; + + private static (ICSharpDbClient Client, ScalarClientProxy Proxy) Client(Func> response) + { + var client = DispatchProxy.Create(); + var proxy = (ScalarClientProxy)client; + proxy.Response = response; + return (client, proxy); + } + + private static async Task MaterializedScalarAsync( + RemoteDatabaseSession session, string sql, CancellationToken cancellationToken) + { + await using QueryResult result = await session.ExecuteAsync(sql, cancellationToken); + if (!result.IsQuery || result.Schema.Length == 0 || !await result.MoveNextAsync(cancellationToken)) + return null; + var column = result.Schema[0]; + return column.DeclaredType is null + ? TypeMapper.GetClrValue(result.Current[0]) + : TypeMapper.GetClrValue(result.Current[0], column.EffectiveType); + } + + private sealed class ExplodingValue + { + public override string ToString() => throw new InvalidOperationException("unused cell conversion failed"); + } + + public sealed record Dispatch(string Method, string Sql, string? TransactionId, CancellationToken Token); + + public class ScalarClientProxy : DispatchProxy + { + public Func> Response { get; set; } = null!; + public List Dispatches { get; } = []; + public int Begins { get; private set; } + public int Commits { get; private set; } + public int Rollbacks { get; private set; } + public int Disposals { get; private set; } + + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + switch (targetMethod!.Name) + { + case nameof(ICSharpDbClient.ExecuteSqlAsync): + Dispatches.Add(new(targetMethod.Name, (string)args![0]!, null, (CancellationToken)args[1]!)); + return Response(); + case nameof(ICSharpDbClient.ExecuteInTransactionAsync): + Dispatches.Add(new(targetMethod.Name, (string)args![1]!, (string)args[0]!, (CancellationToken)args[2]!)); + return Response(); + case nameof(ICSharpDbClient.BeginTransactionAsync): + Begins++; + return Task.FromResult(new TransactionSessionInfo { TransactionId = "scalar-transaction", ExpiresAtUtc = DateTime.UtcNow.AddMinutes(1) }); + case nameof(ICSharpDbClient.CommitTransactionAsync): + Commits++; + return Task.CompletedTask; + case nameof(ICSharpDbClient.RollbackTransactionAsync): + Rollbacks++; + return Task.CompletedTask; + case nameof(ICSharpDbClient.DisposeAsync): + Disposals++; + return ValueTask.CompletedTask; + default: + throw new NotSupportedException(targetMethod.Name); + } + } + } +} diff --git a/tests/CSharpDB.Data.Tests/RemoteSessionResetTests.cs b/tests/CSharpDB.Data.Tests/RemoteSessionResetTests.cs new file mode 100644 index 00000000..8ebe919c --- /dev/null +++ b/tests/CSharpDB.Data.Tests/RemoteSessionResetTests.cs @@ -0,0 +1,79 @@ +using System.Reflection; +using CSharpDB.Client; +using CSharpDB.Client.Models; + +namespace CSharpDB.Data.Tests; + +public sealed class RemoteSessionResetTests +{ + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task FailedRollback_StillDisposesClient_AndIsAttemptedOnlyOnce(bool explicitRollback, bool synchronousFailure) + { + var client = DispatchProxy.Create(); + var proxy = (TransactionClientProxy)client; + proxy.FailRollback = true; + proxy.SynchronousFailure = synchronousFailure; + var session = new RemoteDatabaseSession(client); + await session.BeginTransactionAsync(TestContext.Current.CancellationToken); + if (explicitRollback) + await Assert.ThrowsAsync(() => session.RollbackAsync(TestContext.Current.CancellationToken).AsTask()); + await session.DisposeAsync(); + await session.DisposeAsync(); + Assert.Equal(1, proxy.Disposals); + Assert.Equal(1, proxy.Rollbacks); + Assert.Equal("transaction-id", proxy.LastTransactionId); + if (!explicitRollback) Assert.True(proxy.LastRollbackToken.CanBeCanceled); + } + + [Fact] + public async Task FailedCommit_IsNotReplayed_AndCleanupDisposesClient() + { + var client = DispatchProxy.Create(); + var proxy = (TransactionClientProxy)client; + var session = new RemoteDatabaseSession(client); + await session.BeginTransactionAsync(TestContext.Current.CancellationToken); + await Assert.ThrowsAsync(() => session.CommitAsync(TestContext.Current.CancellationToken).AsTask()); + await session.DisposeAsync(); + Assert.Equal(1, proxy.Disposals); + Assert.Equal(1, proxy.Commits); + Assert.Equal(1, proxy.Rollbacks); + } + + public class TransactionClientProxy : DispatchProxy + { + public bool FailRollback { get; set; } + public bool SynchronousFailure { get; set; } + public int Disposals { get; private set; } + public int Rollbacks { get; private set; } + public int Commits { get; private set; } + public string? LastTransactionId { get; private set; } + public CancellationToken LastRollbackToken { get; private set; } + + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + switch (targetMethod!.Name) + { + case nameof(ICSharpDbClient.DisposeAsync): + Disposals++; + return ValueTask.CompletedTask; + case nameof(ICSharpDbClient.BeginTransactionAsync): + return Task.FromResult(new TransactionSessionInfo { TransactionId = "transaction-id", ExpiresAtUtc = DateTime.UtcNow.AddMinutes(1) }); + case nameof(ICSharpDbClient.RollbackTransactionAsync): + Rollbacks++; + LastTransactionId = (string)args![0]!; + LastRollbackToken = (CancellationToken)args[1]!; + if (!FailRollback) return Task.CompletedTask; + if (SynchronousFailure) throw new HttpRequestException("Injected rollback failure"); + return Task.FromException(new HttpRequestException("Injected rollback failure")); + case nameof(ICSharpDbClient.CommitTransactionAsync): + Commits++; + return Task.FromException(new HttpRequestException("Injected commit failure")); + default: throw new NotSupportedException(targetMethod.Name); + } + } + } +} diff --git a/tests/CSharpDB.Data.Tests/StreamingGrpcConnectionTests.cs b/tests/CSharpDB.Data.Tests/StreamingGrpcConnectionTests.cs new file mode 100644 index 00000000..e6ed816e --- /dev/null +++ b/tests/CSharpDB.Data.Tests/StreamingGrpcConnectionTests.cs @@ -0,0 +1,61 @@ +using CSharpDB.Client; +using CSharpDB.Testing; + +namespace CSharpDB.Data.Tests; + +[Collection("ConnectionPoolState")] +public sealed class StreamingGrpcConnectionTests +{ + [Fact] + public async Task SdkAndAdo_RentTheSamePersistentSession_AndResetTransactions() + { + var ct = TestContext.Current.CancellationToken; + await using var host = new GrpcSocketDaemonFixture(); + var options = new CSharpDbClientOptions + { + Transport = CSharpDbTransport.Grpc, Endpoint = host.NativeEndpoint.AbsoluteUri, + GrpcMode = CSharpDbGrpcMode.Native, GrpcCallMode = CSharpDbGrpcCallMode.Streaming, + }; + string cs = $"Transport=Grpc;Endpoint={host.NativeEndpoint};Grpc Mode=Native;Grpc Call Mode=Streaming;Pooling=true;Max Pool Size=1"; + try + { + await using (var sdk = await CSharpDbClient.RentAsync(options, 1, ct)) + Assert.Null((await sdk.ExecuteSqlAsync("CREATE TABLE ado_stream (id INTEGER PRIMARY KEY, value DECIMAL(10,2)); INSERT INTO ado_stream VALUES (1, 12.34)", ct)).Error); + await using (var connection = new CSharpDbConnection(cs)) + { + await connection.OpenAsync(ct); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT value FROM ado_stream WHERE id = @id"; + command.Parameters.AddWithValue("@id", 1); + command.Prepare(); + Assert.Equal(12.34m, await command.ExecuteScalarAsync(ct)); + await using var transaction = await connection.BeginTransactionAsync(ct); + command.Transaction = transaction; + command.CommandText = "INSERT INTO ado_stream VALUES (2, 99.50)"; + Assert.Equal(1, await command.ExecuteNonQueryAsync(ct)); + // Returning this connection abandons the uncommitted insert. + } + await using (var sdk = await CSharpDbClient.RentAsync(options, 1, ct)) + Assert.Equal(1L, (await sdk.ExecuteSqlAsync("SELECT COUNT(*) FROM ado_stream", ct)).Rows![0][0]); + Assert.Equal(1, host.Snapshot().Where(p => p.Key.Method.EndsWith("/CommandSession")).Sum(p => p.Value)); + Assert.All(host.Snapshot(), p => { Assert.Equal("HTTP/2", p.Key.Protocol); Assert.Equal("application/grpc", p.Key.ContentType); }); + } + finally { await CSharpDbClient.ClearPoolAsync(host.NativeEndpoint.AbsoluteUri); } + } + + [Theory] + [InlineData("Data Source=:memory:;Grpc Call Mode=Streaming")] + [InlineData("Transport=Grpc;Endpoint=http://localhost;Grpc Call Mode=Streaming")] + [InlineData("Transport=Http;Endpoint=http://localhost;Grpc Mode=Native;Grpc Call Mode=Streaming")] + public async Task Streaming_RejectsOtherTransportsAndImplicitHttpVersion(string connectionString) + { + await using var connection = new CSharpDbConnection(connectionString); + await Assert.ThrowsAsync(() => connection.OpenAsync(TestContext.Current.CancellationToken)); + } + + [Theory] + [InlineData("42")] + [InlineData("unsupported")] + public void UnknownCallMode_IsRejected(string value) + => Assert.Throws(() => new CSharpDbConnectionStringBuilder($"Grpc Call Mode={value}").GrpcCallMode); +} diff --git a/tests/CSharpDB.Migration.Tests/MigrationRejectArtifactWriterTests.cs b/tests/CSharpDB.Migration.Tests/MigrationRejectArtifactWriterTests.cs index a1441407..574ef9bd 100644 --- a/tests/CSharpDB.Migration.Tests/MigrationRejectArtifactWriterTests.cs +++ b/tests/CSharpDB.Migration.Tests/MigrationRejectArtifactWriterTests.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Diagnostics; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; @@ -21,6 +22,98 @@ public sealed class MigrationRejectArtifactWriterTests private const string EvidenceFailureMessage = "The authoritative migration reject evidence cannot be materialized."; + [Fact] + public async Task WriteAsync_PublishesAndReusesPrivateArtifactOnLinuxTmpfs() + { + if (!OperatingSystem.IsLinux()) + return; + string? root = CreateLinuxTmpfsRoot(); + if (root is null) + return; + try + { + ArtifactFixture fixture = await CreateFixtureAsync( + [new OutcomeSpec(AcceptedRows: 1, RejectedValues: ["private-value"])]); + var target = new ArtifactTarget(new ArtifactSnapshot(fixture.Receipts, fixture.Ledger)); + string path = Path.Combine(root, "rejects.jsonl"); + var ct = TestContext.Current.CancellationToken; + + MigrationRejectArtifactWriteResult first = await WriteAsync(fixture, target, path, ct); + byte[] original = await File.ReadAllBytesAsync(path, ct); + MigrationRejectArtifactWriteResult second = await WriteAsync(fixture, target, path, ct); + + Assert.False(first.ReusedExistingArtifact); + Assert.True(second.ReusedExistingArtifact); + Assert.Equal(1, second.RejectedRowCount); + Assert.Equal(first.ArtifactDigest, second.ArtifactDigest); + Assert.Equal(original, await File.ReadAllBytesAsync(path, ct)); + Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(path)); + Assert.Single(Directory.EnumerateFiles(root)); + } + finally { DeleteRoot(root); } + } + + [Theory] + [InlineData(UnixFileMode.GroupRead)] + [InlineData(UnixFileMode.OtherRead)] + public async Task WriteAsync_RejectsNonPrivateExistingArtifactOnLinuxTmpfs(UnixFileMode extraAccess) + { + if (!OperatingSystem.IsLinux()) + return; + string? root = CreateLinuxTmpfsRoot(); + if (root is null) + return; + try + { + ArtifactFixture fixture = await CreateFixtureAsync([]); + var target = new ArtifactTarget(new ArtifactSnapshot([], [])); + string path = Path.Combine(root, "rejects.jsonl"); + var ct = TestContext.Current.CancellationToken; + _ = await WriteAsync(fixture, target, path, ct); + byte[] original = await File.ReadAllBytesAsync(path, ct); + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | extraAccess); + + InvalidDataException error = await Assert.ThrowsAsync( + async () => await WriteAsync(fixture, target, path, ct)); + + Assert.Contains("grants group or other access", error.Message, StringComparison.Ordinal); + Assert.Equal(original, await File.ReadAllBytesAsync(path, ct)); + Assert.Single(Directory.EnumerateFiles(root)); + } + finally { DeleteRoot(root); } + } + + [Fact] + public async Task WriteAsync_RejectsPosixAclOnExistingArtifactOnLinuxTmpfs() + { + if (!OperatingSystem.IsLinux()) + return; + string? root = CreateLinuxTmpfsRoot(); + if (root is null) + return; + try + { + ArtifactFixture fixture = await CreateFixtureAsync([]); + var target = new ArtifactTarget(new ArtifactSnapshot([], [])); + string path = Path.Combine(root, "rejects.jsonl"); + var ct = TestContext.Current.CancellationToken; + _ = await WriteAsync(fixture, target, path, ct); + byte[] original = await File.ReadAllBytesAsync(path, ct); + AddLinuxPosixAclWithNoEffectiveAccess(path); + // The ACL mask keeps mode 0600: rejection must inspect the policy, + // rather than relying only on group/other mode bits. + Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(path)); + + InvalidDataException error = await Assert.ThrowsAsync( + async () => await WriteAsync(fixture, target, path, ct)); + + Assert.Contains("contains an extended access policy", error.Message, StringComparison.Ordinal); + Assert.Equal(original, await File.ReadAllBytesAsync(path, ct)); + Assert.Single(Directory.EnumerateFiles(root)); + } + finally { DeleteRoot(root); } + } + [Fact] public async Task WriteAsync_PublishesHeaderOnlyArtifactForEmptyEvidence() { @@ -1445,6 +1538,45 @@ private static string CreateRoot() return root; } + [SupportedOSPlatform("linux")] + private static string? CreateLinuxTmpfsRoot() + { + const string mount = "/dev/shm"; + if (!Directory.Exists(mount) || new DriveInfo(mount).DriveFormat != "tmpfs") + return null; + string root = Path.Combine(mount, $"csharpdb-reject-artifact-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + return root; + } + + [SupportedOSPlatform("linux")] + private static void AddLinuxPosixAclWithNoEffectiveAccess(string path) + { + uint otherUser = checked(LinuxEffectiveUserId() + 1); + IntPtr acl = LinuxAclFromText($"u::rw-,u:{otherUser}:r--,g::---,m::---,o::---"); + if (acl == IntPtr.Zero) + throw new IOException("Could not create the test POSIX ACL.", new Win32Exception(Marshal.GetLastPInvokeError())); + try + { + if (LinuxSetFileAcl(path, 0x8000, acl) != 0) + throw new IOException("Could not set the test POSIX ACL.", new Win32Exception(Marshal.GetLastPInvokeError())); + } + finally { _ = LinuxFreeAcl(acl); } + } + + [DllImport("libacl.so.1", EntryPoint = "acl_from_text", SetLastError = true)] + private static extern IntPtr LinuxAclFromText([MarshalAs(UnmanagedType.LPUTF8Str)] string text); + + [DllImport("libacl.so.1", EntryPoint = "acl_set_file", SetLastError = true)] + private static extern int LinuxSetFileAcl([MarshalAs(UnmanagedType.LPUTF8Str)] string path, int type, IntPtr acl); + + [DllImport("libacl.so.1", EntryPoint = "acl_free", SetLastError = true)] + private static extern int LinuxFreeAcl(IntPtr acl); + + [DllImport("libc", EntryPoint = "geteuid")] + private static extern uint LinuxEffectiveUserId(); + private static FileStream CreatePrivateStaleFile(string path) { if (OperatingSystem.IsWindows()) diff --git a/tests/CSharpDB.Native.ArchiveSmoke/CSharpDB.Native.ArchiveSmoke.csproj b/tests/CSharpDB.Native.ArchiveSmoke/CSharpDB.Native.ArchiveSmoke.csproj new file mode 100644 index 00000000..3898f17d --- /dev/null +++ b/tests/CSharpDB.Native.ArchiveSmoke/CSharpDB.Native.ArchiveSmoke.csproj @@ -0,0 +1,19 @@ + + + Exe + net10.0 + enable + enable + true + false + true + true + false + true + true + true + + + + + diff --git a/tests/CSharpDB.Native.ArchiveSmoke/Program.cs b/tests/CSharpDB.Native.ArchiveSmoke/Program.cs new file mode 100644 index 00000000..0756a94a --- /dev/null +++ b/tests/CSharpDB.Native.ArchiveSmoke/Program.cs @@ -0,0 +1,119 @@ +using System.Runtime.InteropServices; +using CSharpDB.ImportExport.TableArchives; +using CSharpDB.Primitives; + +if (args.Length != 1) + throw new ArgumentException("Pass the absolute path to the published CSharpDB.Native library."); +string libraryPath = Path.GetFullPath(args[0]); +if (!File.Exists(libraryPath)) throw new FileNotFoundException("Published native library not found.", libraryPath); +nint library = NativeLibrary.Load(libraryPath); +NativeLibrary.SetDllImportResolver(typeof(NativeMethods).Assembly, + (name, _, _) => name == NativeMethods.LibraryName ? library : 0); + +string temporaryRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(Path.GetTempPath())); +string directory = Path.Combine(temporaryRoot, $"csharpdb-native-archive-{Guid.NewGuid():N}"); +Directory.CreateDirectory(directory); +string databasePath = Path.Combine(directory, "archive.db"); +try +{ + using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(1)); + var schema = new TableSchema + { + TableName = "source_items", + Columns = + [ + new ColumnDefinition { Name = "id", Type = DbType.Integer, DeclaredType = SqlTypeDescriptor.Create(SqlTypeKind.BigInt), IsPrimaryKey = true, Nullable = false }, + new ColumnDefinition { Name = "label", Type = DbType.Text, DeclaredType = SqlTypeDescriptor.Create(SqlTypeKind.VarChar, length: 32), Nullable = false }, + ], + }; + DbValue[][] rows = + [ + [DbValue.FromInteger(1), DbValue.FromText("Alpha")], + [DbValue.FromInteger(2), DbValue.FromText("Beta")], + ]; + // When this executable is published with NativeAOT, fixture writing also + // proves that archive JSON serialization works with reflection disabled. + string archivePath = Path.Combine(directory, "items.csdbtable"); + await TableArchiveWriter.WriteAsync(archivePath, schema, + TableArchiveWriter.ToAsyncRows(rows, deadline.Token), deadline.Token); + if (!await TableArchiveReader.HasIntegerPrimaryKeyIndexAsync(archivePath, deadline.Token)) + throw new InvalidOperationException("Archive fixture has no primary-key lookup index."); + nint db = Open(databasePath); + try + { + Execute(db, "CREATE EXTERNAL TABLE archived_items FROM 'items.csdbtable'"); + CheckRows(db, "SELECT id, label FROM archived_items ORDER BY id", [(1, "Alpha"), (2, "Beta")]); + CheckRows(db, "SELECT id, label FROM archived_items WHERE id = 2", [(2, "Beta")]); + } + finally { NativeMethods.Close(db); } + db = Open(databasePath); + try { CheckRows(db, "SELECT id, label FROM archived_items WHERE id = 1", [(1, "Alpha")]); } + finally { NativeMethods.Close(db); } + Console.WriteLine("Published native archive SQL smoke passed: generated fixture, scan, indexed lookup, declared types and reopen."); +} +finally +{ + if (!string.Equals(Path.GetDirectoryName(Path.GetFullPath(directory)), temporaryRoot, StringComparison.Ordinal)) + throw new InvalidOperationException("Archive smoke cleanup escaped its temporary directory."); + // Every result and database handle has been released before cleanup. + Directory.Delete(directory, recursive: true); +} + +static nint Open(string path) +{ + nint db = NativeMethods.Open(path); + return db != 0 ? db : throw Failure("Open"); +} + +static void Execute(nint db, string sql) +{ + nint result = NativeMethods.Execute(db, sql); + if (result == 0) throw Failure(sql); + NativeMethods.Free(result); +} + +static void CheckRows(nint db, string sql, (long Id, string Label)[] expected) +{ + nint result = NativeMethods.Execute(db, sql); + if (result == 0) throw Failure(sql); + try + { + if (Marshal.PtrToStringUTF8(NativeMethods.DeclaredType(result, 0)) != "BIGINT" || + Marshal.PtrToStringUTF8(NativeMethods.DeclaredType(result, 1)) != "VARCHAR(32)") + throw new InvalidOperationException("Archive declared types changed."); + foreach (var row in expected) + { + if (NativeMethods.Next(result) != 1 || NativeMethods.Int64(result, 0) != row.Id || + Marshal.PtrToStringUTF8(NativeMethods.Text(result, 1)) != row.Label) + throw Failure("Archive row differs"); + } + if (NativeMethods.Next(result) != 0) throw Failure("Unexpected trailing archive row"); + } + finally { NativeMethods.Free(result); } +} + +static Exception Failure(string operation) => + new InvalidOperationException($"{operation}: {Marshal.PtrToStringUTF8(NativeMethods.LastError())}"); + +internal static partial class NativeMethods +{ + internal const string LibraryName = "csharpdb_archive_smoke"; + [LibraryImport(LibraryName, EntryPoint = "csharpdb_open", StringMarshalling = StringMarshalling.Utf8)] + internal static partial nint Open(string path); + [LibraryImport(LibraryName, EntryPoint = "csharpdb_close")] + internal static partial void Close(nint db); + [LibraryImport(LibraryName, EntryPoint = "csharpdb_execute", StringMarshalling = StringMarshalling.Utf8)] + internal static partial nint Execute(nint db, string sql); + [LibraryImport(LibraryName, EntryPoint = "csharpdb_result_next")] + internal static partial int Next(nint result); + [LibraryImport(LibraryName, EntryPoint = "csharpdb_result_get_int64")] + internal static partial long Int64(nint result, int column); + [LibraryImport(LibraryName, EntryPoint = "csharpdb_result_get_text")] + internal static partial nint Text(nint result, int column); + [LibraryImport(LibraryName, EntryPoint = "csharpdb_result_column_declared_type")] + internal static partial nint DeclaredType(nint result, int column); + [LibraryImport(LibraryName, EntryPoint = "csharpdb_result_free")] + internal static partial void Free(nint result); + [LibraryImport(LibraryName, EntryPoint = "csharpdb_last_error")] + internal static partial nint LastError(); +} diff --git a/tests/CSharpDB.Native.ArchiveSmoke/README.md b/tests/CSharpDB.Native.ArchiveSmoke/README.md new file mode 100644 index 00000000..f607002c --- /dev/null +++ b/tests/CSharpDB.Native.ArchiveSmoke/README.md @@ -0,0 +1,23 @@ +# Published native archive SQL smoke + +This executable writes a table archive, loads the actual published CSharpDB +native library, registers an external table, and verifies a scan, primary-key +lookup, declared SQL types and close/reopen persistence through the C API. +Publishing the smoke itself with NativeAOT also exercises the archive writer +with reflection-based JSON serialization disabled. + +Run it after publishing both projects for the current machine, for example: + +```powershell +dotnet publish src/CSharpDB.Native/CSharpDB.Native.csproj -c Release -r win-x64 -p:ILLinkTreatWarningsAsErrors=true -p:IlcTreatWarningsAsErrors=true +dotnet publish tests/CSharpDB.Native.ArchiveSmoke/CSharpDB.Native.ArchiveSmoke.csproj -c Release -r win-x64 --self-contained true -p:PublishAot=true -o artifacts/native-archive-smoke/win-x64 +./artifacts/native-archive-smoke/win-x64/CSharpDB.Native.ArchiveSmoke.exe (Resolve-Path src/CSharpDB.Native/bin/Release/net10.0/win-x64/publish/CSharpDB.Native.dll) +``` + +CI runs the corresponding `linux-x64`, `win-x64` and `osx-arm64` builds. The +macOS job uses a private directory under `/private/tmp` so the archive path has +no temporary-directory symlink. Native compiler prerequisites are required +only for publishing; the ordinary solution build remains managed. + +The smoke checks archive-backed SQL through the native C ABI. Remote gRPC and +performance qualification have separate validation requirements. diff --git a/tests/CSharpDB.NetworkBenchmarks/BenchmarkDaemonHost.cs b/tests/CSharpDB.NetworkBenchmarks/BenchmarkDaemonHost.cs new file mode 100644 index 00000000..14a8cc52 --- /dev/null +++ b/tests/CSharpDB.NetworkBenchmarks/BenchmarkDaemonHost.cs @@ -0,0 +1,187 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text.Json; +using CSharpDB.Client; +using CSharpDB.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace CSharpDB.NetworkBenchmarks; + +/// Owns exactly one real daemon, optionally in a separate child process. +internal sealed class BenchmarkDaemonHost : IAsyncDisposable +{ + private readonly GrpcSocketDaemonFixture? _fixture; + private readonly GrpcSessionBenchmark.WireCounters? _counters; + private readonly Process? _child; + private readonly Task? _stderr; + public HostInfo Info { get; } + public Uri CompatibilityEndpoint => new(Info.CompatibilityEndpoint); + public Uri NativeEndpoint => new(Info.NativeEndpoint); + public Uri EndpointFor(CSharpDbGrpcMode mode) => mode == CSharpDbGrpcMode.Native ? NativeEndpoint : CompatibilityEndpoint; + + private BenchmarkDaemonHost(GrpcSocketDaemonFixture fixture, GrpcSessionBenchmark.WireCounters counters) + { + _fixture = fixture; _counters = counters; + Info = new(Environment.ProcessId, Environment.ProcessId, RuntimeInformation.FrameworkDescription, + System.Runtime.GCSettings.IsServerGC, fixture.CompatibilityEndpoint.AbsoluteUri, fixture.NativeEndpoint.AbsoluteUri); + } + private BenchmarkDaemonHost(Process child, Task stderr, HostInfo info) + { _child = child; _stderr = stderr; Info = info; } + + public static async Task StartAsync(bool isolated, SessionDiagnosticProbe? probe, CancellationToken ct) + { + if (!isolated) + { + var counters = new GrpcSessionBenchmark.WireCounters(); + var fixture = new GrpcSocketDaemonFixture(configureServices: services => + { + services.AddSingleton(counters); + services.AddGrpc(options => options.Interceptors.Add()); + probe?.Configure(services); + }); + return new(fixture, counters); + } + if (probe is not null) throw new InvalidOperationException("Isolated qualification must not install diagnostic probes."); + var start = new ProcessStartInfo("dotnet") + { RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; + start.ArgumentList.Add(typeof(BenchmarkDaemonHost).Assembly.Location); + start.ArgumentList.Add("--session-daemon"); + start.Environment["CSHARPDB_BENCHMARK_PARENT"] = Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture); + // A launch-attached client trace must not suspend the separate server on the same diagnostic port. + start.Environment.Remove("DOTNET_DiagnosticPorts"); + start.Environment.Remove("DOTNET_DefaultDiagnosticPortSuspend"); + var child = Process.Start(start) ?? throw new InvalidOperationException("Could not start owned benchmark daemon."); + Task stderr = child.StandardError.ReadToEndAsync(); + try + { + string? line = await child.StandardOutput.ReadLineAsync(ct).AsTask().WaitAsync(TimeSpan.FromSeconds(30), ct); + HostInfo? info; + try { info = line is null ? null : JsonSerializer.Deserialize(line); } + catch (JsonException error) { throw new InvalidOperationException($"Invalid child handshake: {line}", error); } + if (info is null || info.ServerProcessId != child.Id || info.ParentProcessId != Environment.ProcessId) + throw new InvalidOperationException($"Invalid child handshake: {line}"); + return new(child, stderr, info); + } + catch + { + if (!child.HasExited) child.Kill(entireProcessTree: true); + await child.WaitForExitAsync(CancellationToken.None); + string error = await stderr; + child.Dispose(); + if (error.Length > 0) Console.Error.WriteLine(error); + throw; + } + } + public async Task CaptureAsync(CancellationToken ct) + { + if (_fixture is not null) return Capture(_fixture, _counters!, false); + await _child!.StandardInput.WriteLineAsync("snapshot".AsMemory(), ct); + await _child.StandardInput.FlushAsync(ct); + string? line = await _child.StandardOutput.ReadLineAsync(ct).AsTask().WaitAsync(TimeSpan.FromSeconds(10), ct); + return line is null ? throw new IOException("Owned daemon closed its control pipe.") + : JsonSerializer.Deserialize(line) ?? throw new IOException("Invalid daemon snapshot."); + } + + public async Task CaptureTimingAsync(CancellationToken ct) + { + if (_fixture is not null) return CaptureTiming(_fixture, _counters!); + await _child!.StandardInput.WriteLineAsync("snapshot-timing".AsMemory(), ct); + await _child.StandardInput.FlushAsync(ct); + string? line = await _child.StandardOutput.ReadLineAsync(ct).AsTask().WaitAsync(TimeSpan.FromSeconds(10), ct); + return line is null ? throw new IOException("Owned daemon closed its control pipe.") + : JsonSerializer.Deserialize(line) ?? throw new IOException("Invalid daemon timing snapshot."); + } + + public async Task CaptureTimelineClockAsync(int id, CancellationToken ct) + { + if (id is not (1 or 2)) throw new ArgumentOutOfRangeException(nameof(id)); + if (_fixture is not null) return CaptureTimelineClock(id); + await _child!.StandardInput.WriteLineAsync($"timeline-clock:{id}".AsMemory(), ct); + await _child.StandardInput.FlushAsync(ct); + string? line = await _child.StandardOutput.ReadLineAsync(ct).AsTask().WaitAsync(TimeSpan.FromSeconds(10), ct); + return line is null ? throw new IOException("Owned daemon closed its timeline clock pipe.") + : JsonSerializer.Deserialize(line) ?? throw new IOException("Invalid daemon timeline clock."); + } + + private static TimelineClock CaptureTimelineClock(int id) + { + long qpc = Stopwatch.GetTimestamp(); + TimelineEventSource.Log.Clock(id, qpc, Stopwatch.Frequency); + return new(id, qpc, Stopwatch.Frequency); + } + + private static TimingSnapshot CaptureTiming(GrpcSocketDaemonFixture fixture, GrpcSessionBenchmark.WireCounters counters) + { + using var process = Process.GetCurrentProcess(); + return new(fixture.Snapshot().Select(x => new HttpCount(x.Key.Protocol, x.Key.ContentType, x.Key.Method, x.Value)).ToArray(), + counters.Snapshot(), process.TotalProcessorTime.TotalMilliseconds); + } + + private static Snapshot Capture(GrpcSocketDaemonFixture fixture, GrpcSessionBenchmark.WireCounters counters, bool separate) + { + using var process = separate ? Process.GetCurrentProcess() : null; + return new(fixture.Snapshot().Select(x => new HttpCount(x.Key.Protocol, x.Key.ContentType, x.Key.Method, x.Value)).ToArray(), + counters.Snapshot(), separate ? GC.GetTotalAllocatedBytes(true) : 0, process?.TotalProcessorTime.TotalMilliseconds ?? 0); + } + public static async Task RunChildAsync() + { + try + { + var counters = new GrpcSessionBenchmark.WireCounters(); + await using var fixture = new GrpcSocketDaemonFixture(configureServices: services => + { + services.AddSingleton(counters); + services.AddGrpc(options => options.Interceptors.Add()); + }); + int parent = int.Parse(Environment.GetEnvironmentVariable("CSHARPDB_BENCHMARK_PARENT")!); + Console.WriteLine(JsonSerializer.Serialize(new HostInfo(parent, Environment.ProcessId, RuntimeInformation.FrameworkDescription, + System.Runtime.GCSettings.IsServerGC, fixture.CompatibilityEndpoint.AbsoluteUri, fixture.NativeEndpoint.AbsoluteUri))); + string? command; + while ((command = await Console.In.ReadLineAsync()) is not null) + { + if (command == "stop") return 0; + if (command == "snapshot-timing") + { + Console.WriteLine(JsonSerializer.Serialize(CaptureTiming(fixture, counters))); + continue; + } + if (command.StartsWith("timeline-clock:", StringComparison.Ordinal)) + { + if (!int.TryParse(command.AsSpan("timeline-clock:".Length), + System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out int id) + || id is not (1 or 2)) throw new InvalidOperationException("Invalid timeline clock anchor."); + Console.WriteLine(JsonSerializer.Serialize(CaptureTimelineClock(id))); + continue; + } + if (command != "snapshot") throw new InvalidOperationException("Unknown benchmark control command."); + Console.WriteLine(JsonSerializer.Serialize(Capture(fixture, counters, true))); + } + return 0; + } + catch (Exception error) { Console.Error.WriteLine(error); return 1; } + } + public async ValueTask DisposeAsync() + { + if (_fixture is not null) { await _fixture.DisposeAsync(); return; } + try + { + if (!_child!.HasExited) + { + await _child.StandardInput.WriteLineAsync("stop"); + await _child.StandardInput.FlushAsync(); + await _child.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + } + if (_child.ExitCode != 0) throw new InvalidOperationException($"Owned daemon exited {_child.ExitCode}: {await _stderr!}"); + } + finally + { + if (!_child!.HasExited) { _child.Kill(entireProcessTree: true); await _child.WaitForExitAsync(); } + _child.Dispose(); + } + } + internal sealed record HostInfo(int ParentProcessId, int ServerProcessId, string Runtime, bool IsServerGC, string CompatibilityEndpoint, string NativeEndpoint); + internal sealed record HttpCount(string Protocol, string ContentType, string Method, long Value); + internal sealed record Snapshot(HttpCount[] Http, GrpcSessionBenchmark.Counts Wire, long ServerAllocatedBytes, double ServerCpuMs); + internal sealed record TimingSnapshot(HttpCount[] Http, GrpcSessionBenchmark.Counts Wire, double ServerCpuMs); + internal sealed record TimelineClock(int Id, long Qpc, long Frequency); +} diff --git a/tests/CSharpDB.NetworkBenchmarks/CLOSEOUT.md b/tests/CSharpDB.NetworkBenchmarks/CLOSEOUT.md new file mode 100644 index 00000000..0d3998df --- /dev/null +++ b/tests/CSharpDB.NetworkBenchmarks/CLOSEOUT.md @@ -0,0 +1,139 @@ +# Network experiment closeout + +The gRPC micro-optimization phase is closed. The retained work is a reusable +client and session foundation; the recent isolated optimization candidates did +not establish the required performance improvement. The current gRPC transport +remains available, and persistent streaming remains experimental and opt-in. +Unary calls remain the default. + +This checkpoint does not add a custom TCP transport or remote prepared-statement +handles. A native transport is a separate proposed experiment with its own +protocol, correctness checks, and performance decision. + +## Foundation retained + +| Area | Retained behavior | Boundary for a future transport | +| --- | --- | --- | +| Shared SDK and ADO.NET access | ADO.NET rents the same SDK client and physical pool for matching options. | Preserve the public client contract and one shared access path. | +| Pool and logical leases | Capacity limits, cancelable waiting, credential/route isolation, retirement, stale-rental rejection, transaction cleanup, and draining before return. | Pool and lease classes currently contain gRPC dependencies; extract their ownership rules when another transport needs them. | +| Command sessions | Version/capability negotiation, consecutive request IDs, one command in flight, SQL and transaction commands, and disconnect rollback. | Existing Protobuf payloads can be reused initially; stream I/O and server dispatch still depend on gRPC. | +| Results | Logical type mapping, private schema reuse, and compact scalar projection through the existing rental. | Keep type and ownership semantics independent of a future frame encoding. | +| Correctness tests | Pool isolation, transaction ownership, cancellation, disposal/draining, malformed responses, schema ownership, and no replay. | Reuse portable behavior tests and provide transport-specific fixtures. | +| Benchmark harness | Held-client and rent/query/return workloads, fixed schedules, raw timings, CPU/wire checks, and preserved failures. | A new transport requires its own frozen wire validation and comparison protocol. | + +The main source boundaries are [the SDK rental API](../../src/CSharpDB.Client/CSharpDbClient.cs), +[pool](../../src/CSharpDB.Client/Internal/GrpcClientPool.cs), +[lease](../../src/CSharpDB.Client/Internal/GrpcClientLease.cs), +[client session](../../src/CSharpDB.Client/Internal/GrpcCommandSession.cs), +[server session](../../src/CSharpDB.Daemon/Grpc/CSharpDbRpcService.Session.cs), +[wire messages](../../src/CSharpDB.Client/Protos/csharpdb_rpc.proto), and +[ADO.NET integration](../../src/CSharpDB.Data/CSharpDbConnection.cs). + +The engine/transaction ownership work belongs to the dependency review for this +foundation. Sharing a database owner must preserve private temporary-table +state, transaction bindings, cleanup, and runtime-diagnostics ownership. These +changes are not a transport speedup claim. Separate storage and benchmark +commits from the research branch remain outside this foundation checkpoint. + +## Final retained evidence + +These are historical studies of frozen external runtimes, not validation counts +for a newly rebuilt foundation checkout. + +| Study | Scope | Outcome | +| --- | --- | --- | +| Streaming client allocation profile | Two fresh baseline processes, 64 blocks, 2,097,152 measured reads, and four traces. Both client and server used three GC heaps with dynamic adaptation disabled. | Diagnostic integrity passed with zero trace loss. Process-wide allocation counters include diagnostics/background activity; sampled type weights are approximate. No performance qualification or candidate installation. | +| Private `ExchangeAsync` state pooling | Eight fresh processes in B/C/C/B order per streaming/control stage, 200 blocks, and 5,373,952 measured requests. Default GC; no tracing or allocation probes. | Raw-data integrity passed; performance qualification failed. All 2,058 correctness cases passed across both variants: 434 daemon and 595 Data cases per variant, including nine common lifetime cases. The candidate remained isolated. | + +The private-exchange candidate changed only its private async builder and +`Task` return type to pooled `ValueTask`. Its method +body and all three direct-await callers were unchanged. Both client assemblies +were rebuilt with the same toolchain; the other 387 runtime files were retained. + +| Rent/query/return path | First mirrored comparison: throughput change | Second mirrored comparison: throughput change | +| --- | ---: | ---: | +| SDK | +0.01% | -1.11% | +| ADO.NET | -3.18% | +0.76% | + +Both paths missed the predeclared 5% improvement requirement. Several tail and +control gates also failed; the original stability gates passed. Latency gates +used the median of fixed-round block p99 values and median block worst-worker +p99, rather than a single p99 pooled over every request. No capture was retried +or excluded to improve the decision. A postcapture audit-reader type error was +repaired without changing the frozen captures, gates, or raw arithmetic; the +failed audit and repair receipt were retained. + +The recent qualifications also used an external, frozen `pooled-unary-guard` +helper and entrypoint adaptation. That mode is **not** implemented by this +checkout's [benchmark program](NetworkBenchmarkProgram.cs). Reproducing those +studies requires their retained source/runtime manifests and adapters; invoking +that flag on a newly built checkout does not reproduce them. + +The earlier server-wrapper and other rejected micro-optimization candidates +retain their original evidence and decisions. They are not included in the +foundation merely because they passed correctness tests. + +## Interpretation and next boundary + +The measured workload was a warm, one-row read over cleartext localhost with +eight workers. Client and server were separate processes on the same Windows +host. It does not establish separate-host or TLS performance, write throughput, +maximum capacity, or a performance ceiling for CSharpDB. Initial CPU headroom +and an observational host monitor did not prove scheduling or thermal isolation. + +Preserve the gRPC baseline and archived failures. A future TCP study should +first isolate replacing gRPC/HTTP/2 framing while retaining the query executor, +session semantics, and payload representation. Changing row encoding, prepared +queries, or multiplexing at the same time would require separate attribution. +Any new adoption threshold must be declared before its measurements and must +not alter the decisions of the closed studies. + +## Foundation checkpoint validation + +Actual-source validation on Windows x64 with SDK **10.0.403**, following the +repository's unchanged SDK policy: + +- Solution restore and Release build passed with **zero warnings and errors**. +- The solution-wide Microsoft.Testing.Platform run covered all **27 test + modules**, one module at a time. Final validated results are **8,985 passed, + zero failed, and four skipped**. +- The four skips require optional trusted Access fixtures or live MySQL/SQL + Server administrator connections; those environments were not configured. +- Core engine/storage tests passed all **3,521** cases, daemon tests all **434**, + and Data tests all **595**. This includes nine imported public-task lifetime + cases, four new Admin route-mode cases, and one new commit-preparation failure + disposal case. +- The first complete run exposed one existing observability test race: a + process-global listener count assertion overlapped two health events from + another test. `OperationScopeTests` now uses the existing nonparallel telemetry + collection. Its assertions are unchanged; rebuilding and rerunning the full + **134-case** observability suite passed. The initial failed run, overlapping + event evidence, and corrected run are retained in the closeout archive. +- Documentation, published-package dependency closure, and EF Core version + consistency checks passed. Source and dependency review confirmed that the + rejected private-exchange and server-wrapper candidates are absent. + +The source checkpoint also fixes Admin route cloning to preserve streaming mode, +while keeping Unary as the default. The original experiment's working files, +index, and three storage/benchmark commits remain preserved separately. + +This is Windows source/correctness validation. Cross-platform CI, NativeAOT +publishing, optional live-provider qualifications, and new network performance +qualification were not performed as part of this closeout. The historical +performance decisions above remain unchanged. + +## External archive references + +Raw evidence is intentionally outside this source tree. In the retained research +archive, resolve these directory names under `Documents/Codex/2026-10-26/`. +The directory date is the local work date; receipt timestamps are UTC. + +| Archive directory | Final receipt | SHA-256 of receipt | +| --- | --- | --- | +| `csharpdb-streaming-client-allocations-20261026` | `report-receipt-seal.json` | `193F31A44D970C02A9AAE01B58B6E13AD5C6E7142CC1044AC3B2CFD6A2DD34FF` | +| `csharpdb-private-exchange-pooling-20261026` | `report-receipt-seal.json` | `EA2906AA7AC9CDEADD708AFC73B913C38C3FBD217F7B476093F9CECF41FD4CE9` | + +The receipts index manifests, raw captures, exact binaries, source inventories, +test outputs, analyses, failures, and ownership/cleanup evidence. They are +external research records, not downloadable release artifacts or public +quick-start requirements. diff --git a/tests/CSharpDB.NetworkBenchmarks/CSharpDB.NetworkBenchmarks.csproj b/tests/CSharpDB.NetworkBenchmarks/CSharpDB.NetworkBenchmarks.csproj new file mode 100644 index 00000000..8c7d38bf --- /dev/null +++ b/tests/CSharpDB.NetworkBenchmarks/CSharpDB.NetworkBenchmarks.csproj @@ -0,0 +1,17 @@ + + + Exe + net10.0 + enable + enable + false + true + + + + + + + + + diff --git a/tests/CSharpDB.NetworkBenchmarks/GrpcConcurrentSessionBenchmark.BaselineTimeline.cs b/tests/CSharpDB.NetworkBenchmarks/GrpcConcurrentSessionBenchmark.BaselineTimeline.cs new file mode 100644 index 00000000..2fb39fd6 --- /dev/null +++ b/tests/CSharpDB.NetworkBenchmarks/GrpcConcurrentSessionBenchmark.BaselineTimeline.cs @@ -0,0 +1,485 @@ +using System.Diagnostics; +using System.Diagnostics.Tracing; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text.Json; +using CSharpDB.Client; +using static CSharpDB.NetworkBenchmarks.GrpcSessionBenchmark; + +namespace CSharpDB.NetworkBenchmarks; + +internal static partial class GrpcConcurrentSessionBenchmark +{ + private const int TimelinePerWorker = 32768; + private const int TimelineCount = Workers * TimelinePerWorker; + private const int TimelineChunkLength = 4096; + private static readonly string[] TimelineOrder = ["SDK", "ADO", "ADO", "SDK", "SDK", "ADO", "ADO", "SDK"]; + + public static async Task RunBaselineTimelineAsync(string[] args) + { + string? output = null; + bool ownsOutput = false; + try + { + if (args.Length != 4 || Array.IndexOf(args, "--experiment") is not (0 or 2) + || Array.IndexOf(args, "--output") is not (0 or 2) + || args[Array.IndexOf(args, "--experiment") + 1] != "session-c8-baseline-timeline") + throw new ArgumentException("Use --experiment session-c8-baseline-timeline --output ; all diagnostic workload settings are fixed."); + string root = Git("rev-parse", "--show-toplevel"); + output = Path.GetFullPath(args[Array.IndexOf(args, "--output") + 1]); + if (output.Equals(root, StringComparison.OrdinalIgnoreCase) + || output.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("Timeline evidence must be outside the repository."); + if (Directory.Exists(output)) throw new ArgumentException("Timeline evidence directory must be new."); + Directory.CreateDirectory(output); + ownsOutput = true; + using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(3)); + using var workload = CancellationTokenSource.CreateLinkedTokenSource(deadline.Token); + workload.CancelAfter(TimeSpan.FromSeconds(150)); + await RunBaselineTimelineCoreAsync(root, output, workload.Token, deadline.Token); + return 0; + } + catch (Exception error) + { + if (ownsOutput && output is not null) + { + File.AppendAllText(Path.Combine(output, "failures.jsonl"), JsonSerializer.Serialize(new + { Utc = DateTimeOffset.UtcNow, Error = error.ToString() }) + Environment.NewLine); + Write(output, "status.json", new { State = "failed", FinishedUtc = DateTimeOffset.UtcNow, + ProfileOnly = true, QualificationEligible = false, AllocationAccounting = "NotCollected" }); + } + Console.Error.WriteLine(error); + return 1; + } + } + + private static async Task RunBaselineTimelineCoreAsync(string root, string output, + CancellationToken ct, CancellationToken shutdownCt) + { + string frozenRoot = Directory.GetParent(Path.TrimEndingDirectorySeparator(AppContext.BaseDirectory))!.FullName; + string manifestPath = Path.Combine(frozenRoot, "snapshot.json"); + byte[] manifestBytes = File.ReadAllBytes(manifestPath); + string manifestHash = Convert.ToHexString(SHA256.HashData(manifestBytes)); + var snapshot = JsonSerializer.Deserialize(manifestBytes) + ?? throw new InvalidDataException("Missing frozen timeline baseline manifest."); + if (snapshot.Variant != "baseline" || snapshot.Sources.Length == 0 || snapshot.Binaries.Length == 0) + throw new InvalidDataException("Timeline requires a frozen baseline build."); + VerifyFrozenSources(frozenRoot, snapshot.Sources); + var binaries = BinaryHashes(); + if (!snapshot.Binaries.SequenceEqual(binaries)) throw new InvalidDataException("Timeline frozen runtime hash mismatch."); + var liveSources = SourceHashes(root); + string liveHead = Git("rev-parse", "HEAD"), liveStatus = Git("status", "--porcelain=v1"); + Write(output, "protocol.json", new + { + Version = 1, Experiment = "baseline-stream-timeline-diagnostic", Variant = "baseline", + ProfileOnly = true, QualificationEligible = false, AllocationAccounting = "NotCollected", + Workers, PerWorker = TimelinePerWorker, CountPerPhase = TimelineCount, Phases = TimelineOrder.Length, + TotalTimedOperations = TimelineCount * TimelineOrder.Length, WarmupPerWorker = Warmup, + SettlingSecondsPerArm = 3, SettlingOrder = new[] { "SDK", "ADO" }, PhaseOrder = TimelineOrder, + TimingChunkLength = TimelineChunkLength, PoolCapacity = Workers, DeadlineSeconds = 180, + WorkloadDeadlineSeconds = 150, CleanupReserveSeconds = 30, + CollectorHandshakeSeconds = 30, Query = "SELECT value FROM network_read WHERE id = 1", + EventSource = "CSharpDB-Baseline-Timeline", + PhaseEvent = "1:Phase(int phase,string api,string kind,string edge,long qpc,long completed)", + ClockEvent = "2:Clock(int id,long qpc,long frequency)", + Scope = "Baseline-only diagnostic timeline in separate real client/daemon processes. Eight held streaming rentals/commands, one outstanding read per worker, shared SDK pool. Every read validates scalar42. ProfileOnly=true; this run cannot qualify a product or performance improvement.", + Timing = "Eight fixed phases SDK,ADO,ADO,SDK,SDK,ADO,ADO,SDK. Each phase warms256 reads/worker then retains32768 QPC start/end pairs per worker. One readiness/start barrier; cancel siblings and drain on failure. Raw timings use4096-element chunks, avoiding new large timing arrays. CSV/JSON writes occur after measured-end markers, outside timing windows.", + Accounting = "Timing CPU/control-pipe snapshots only. No allocation counters, forced GC, runtime/ThreadPool/OS tuning, retries or exclusions. EventSource emits only phase boundaries and two QPC anchors per process. Sampled trace events are diagnostic observations, not exact allocations.", + Lifecycle = "Host starts without inherited launch diagnostic ports. attach-ready precedes collectors-ready; no seed or settling occurs before collector acknowledgment. workload-complete precedes collectors-stopped, and the daemon remains alive until acknowledgment or bounded timeout for trace rundown.", + FrozenRoot = frozenRoot, ManifestSha256 = manifestHash, Snapshot = snapshot, Binaries = binaries, + LiveWorkspace = new { Root = root, GitHead = liveHead, GitStatus = liveStatus, Sources = liveSources }, + Stopwatch.Frequency, Runtime = RuntimeInformation.FrameworkDescription, OS = RuntimeInformation.OSDescription, + Environment.MachineName, Environment.ProcessorCount, System.Runtime.GCSettings.IsServerGC, + }); + File.WriteAllBytes(Path.Combine(output, "variant-manifest.json"), manifestBytes); + foreach (var source in snapshot.Sources) + { + string destination = Path.Combine(output, "source", source.Name); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + File.Copy(Path.Combine(frozenRoot, source.Name), destination); + } + Write(output, "status.json", new { State = "running", StartedUtc = DateTimeOffset.UtcNow, + ProfileOnly = true, QualificationEligible = false, AllocationAccounting = "NotCollected" }); + _ = TimelineEventSource.Log; + await using var host = await BenchmarkDaemonHost.StartAsync(true, null, ct); + Write(output, "host-info.json", host.Info); + Write(output, "attach-ready.json", new { ClientProcessId = Environment.ProcessId, + ServerProcessId = host.Info.ServerProcessId, Qpc = Stopwatch.GetTimestamp(), QpcFrequency = Stopwatch.Frequency }); + var settling = new List(); + var phases = new List(); + var clocks = new List(); + var cpu = new TimingCpuLedger(); + bool collectorsReady = false, stopWaitStarted = false, workloadCompleteWritten = false; + try + { + await WaitTimelineCollectorsAsync(output, "collectors-ready.json", host, ct); + collectorsReady = true; + clocks.Add(await SynchronizeTimelineClockAsync(host, 1, ct)); + Write(output, "clock-sync.json", clocks); + await using (var seed = CSharpDbClient.Create(new() + { + Transport = CSharpDbTransport.Grpc, GrpcMode = CSharpDbGrpcMode.Native, + Endpoint = host.NativeEndpoint.AbsoluteUri, + })) + { + var seeded = await seed.ExecuteSqlAsync("CREATE TABLE network_read (id INTEGER PRIMARY KEY, value INTEGER); INSERT INTO network_read VALUES (1, 42)", ct); + if (seeded.Error is not null) throw new InvalidOperationException(seeded.Error); + } + foreach (string api in new[] { "SDK", "ADO" }) + { + await using var group = await Group.OpenAsync(host, new(api, "Stream"), ct); + settling.Add(await RunTimelineUntimedAsync(host, group, 0, api, "settling", output, cpu, ct)); + Write(output, "settling.json", settling); + } + var poolBefore = await host.CaptureTimingAsync(ct); + ValidateTimelineFamily(poolBefore); + cpu.ObserveServer(poolBefore.ServerCpuMs); + Write(output, "pool-proof-before.json", poolBefore); + for (int index = 0; index < TimelineOrder.Length; index++) + { + int phase = index + 1; + string api = TimelineOrder[index]; + await using var group = await Group.OpenAsync(host, new(api, "Stream"), ct); + await RunTimelineUntimedAsync(host, group, phase, api, "warmup", output, cpu, ct); + phases.Add(await MeasureTimelineAsync(host, group, phase, api, output, cpu, ct)); + Write(output, "phases.json", phases); + Console.WriteLine($"Timeline phase{phase} {api}: {phases[^1].Completed} reads; {phases[^1].OpsPerSecond:N0} ops/sec (diagnostic only)"); + } + var poolAfter = await host.CaptureTimingAsync(ct); + ValidateTimelineFamily(poolAfter); + cpu.ObserveServer(poolAfter.ServerCpuMs); + Write(output, "pool-proof-after.json", poolAfter); + int timed = phases.Sum(x => x.Completed), warmup = Warmup * Workers * TimelineOrder.Length; + int settled = settling.Sum(x => x.Completed); + if (phases.Count != 8 || timed != 2097152 || phases.Any(x => x.Completed != TimelineCount)) + throw new InvalidDataException("Timeline fixed phase/read counts are incomplete."); + ValidateWire(poolBefore.Http, poolAfter.Http, poolAfter.Wire - poolBefore.Wire, new("SDK", "Stream"), timed + warmup); + if (poolAfter.Wire.StreamSql != settled + timed + warmup + || poolAfter.Wire.StreamResponses != settled + timed + warmup + 16) + throw new InvalidDataException("Timeline settling/warmup/measured wire totals differ."); + if (!liveSources.SequenceEqual(SourceHashes(root)) || liveHead != Git("rev-parse", "HEAD") + || liveStatus != Git("status", "--porcelain=v1") || !binaries.SequenceEqual(BinaryHashes()) + || manifestHash != Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(manifestPath)))) + throw new InvalidDataException("Timeline source, workspace, manifest or runtime changed."); + VerifyFrozenSources(frozenRoot, snapshot.Sources); + clocks.Add(await SynchronizeTimelineClockAsync(host, 2, ct)); + Write(output, "clock-sync.json", clocks); + Write(output, "summary.json", new + { + ProfileOnly = true, QualificationEligible = false, AllocationAccounting = "NotCollected", + Phases = phases, TotalTimedOperations = timed, TotalWarmupOperations = warmup, SettlingOperations = settled, + SourcesAndBinariesUnchanged = true, WireCountsValidated = true, CpuAccountingValid = true, + }); + Write(output, "workload-complete.json", new { State = "complete", Qpc = Stopwatch.GetTimestamp(), + ClientProcessId = Environment.ProcessId, ServerProcessId = host.Info.ServerProcessId, + Phases = phases.Count, TotalTimedOperations = timed, TotalWarmupOperations = warmup, + ProfileOnly = true, QualificationEligible = false, DiagnosticIntegrityValid = true }); + workloadCompleteWritten = true; + stopWaitStarted = true; + await WaitTimelineCollectorsAsync(output, "collectors-stopped.json", host, shutdownCt); + Write(output, "status.json", new { State = "complete", FinishedUtc = DateTimeOffset.UtcNow, + Phases = phases.Count, TotalTimedOperations = timed, ProfileOnly = true, QualificationEligible = false, + AllocationAccounting = "NotCollected", CpuAccountingValid = true, CollectorsStopped = true }); + } + catch (Exception error) + { + if (!workloadCompleteWritten) + Write(output, "workload-complete.json", new { State = "failed", Qpc = Stopwatch.GetTimestamp(), + ClientProcessId = Environment.ProcessId, ServerProcessId = host.Info.ServerProcessId, + ProfileOnly = true, QualificationEligible = false, DiagnosticIntegrityValid = false, + CollectorsReady = collectorsReady, Error = error.ToString() }); + if (!stopWaitStarted) + { + stopWaitStarted = true; + try { await WaitTimelineCollectorsAsync(output, "collectors-stopped.json", host, shutdownCt); } + catch (Exception stopError) { throw new AggregateException("Timeline workload and collector shutdown failed.", error, stopError); } + } + throw; + } + finally + { + await CSharpDbClient.ClearPoolAsync(host.CompatibilityEndpoint.AbsoluteUri); + await CSharpDbClient.ClearPoolAsync(host.NativeEndpoint.AbsoluteUri); + } + } + + private static async Task SynchronizeTimelineClockAsync(BenchmarkDaemonHost host, int id, CancellationToken ct) + { + long before = Stopwatch.GetTimestamp(); + var server = await host.CaptureTimelineClockAsync(id, ct); + long after = Stopwatch.GetTimestamp(); + TimelineEventSource.Log.Clock(id, after, Stopwatch.Frequency); + if (server.Id != id || server.Frequency != Stopwatch.Frequency || server.Qpc < before || server.Qpc > after) + throw new InvalidDataException("Timeline cross-process QPC anchor is inconsistent."); + return new(id, before, after, after, server.Qpc, Stopwatch.Frequency, Environment.ProcessId, host.Info.ServerProcessId); + } + + private static async Task WaitTimelineCollectorsAsync(string output, string name, BenchmarkDaemonHost host, CancellationToken ct) + { + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct); + deadline.CancelAfter(TimeSpan.FromSeconds(30)); + string path = Path.Combine(output, name); + try + { + while (true) + { + deadline.Token.ThrowIfCancellationRequested(); + if (File.Exists(path)) + { + TimelineCollectors? signal = null; + try { signal = JsonSerializer.Deserialize(File.ReadAllText(path)); } + catch (IOException) { /* External writer may still be replacing its handshake file. */ } + catch (JsonException) { /* Do not accept a partially written handshake. */ } + if (signal is not null) + { + if (signal.ClientProcessId != Environment.ProcessId || signal.ServerProcessId != host.Info.ServerProcessId) + throw new InvalidDataException($"Wrong process identities in {name}."); + string errors = string.Join("; ", (signal.Errors ?? []).Take(8).Select(error => + error is { Length: > 2048 } ? error[..2048] : error)); + bool valid = name == "collectors-ready.json" + ? signal.State == "ready" && signal.Succeeded is not false + : signal.State == "stopped" && signal.Succeeded == true && signal.Outcome == "complete"; + if (!valid || signal.Errors is { Length: > 0 }) + throw new InvalidDataException($"Collector handshake {name} failed: State={signal.State}, Succeeded={signal.Succeeded}, Outcome={signal.Outcome}. {errors}"); + return; + } + } + await Task.Delay(50, deadline.Token); + } + } + catch (OperationCanceledException error) when (!ct.IsCancellationRequested) + { throw new TimeoutException($"Timeline collector handshake {name} did not arrive within30 seconds.", error); } + } + + private static async Task RunTimelineUntimedAsync(BenchmarkDaemonHost host, Group group, + int phase, string api, string kind, string output, TimingCpuLedger cpu, CancellationToken ct) + { + var before = await host.CaptureTimingAsync(ct); + ValidateTimelineFamily(before); + cpu.ObserveServer(before.ServerCpuMs); + var completed = new int[Workers]; + var failures = new string?[Workers]; + using var cancel = CancellationTokenSource.CreateLinkedTokenSource(ct); + long start = Stopwatch.GetTimestamp(), end = 0; + TimelineEventSource.Log.Phase(phase, api, kind, "start", start, 0); + async Task Worker(int worker) + { + try + { + if (kind == "warmup") + for (int n = 0; n < Warmup; n++) { await group.Runners[worker].ReadAsync(cancel.Token); completed[worker]++; } + else + do { await group.Runners[worker].ReadAsync(cancel.Token); completed[worker]++; } + while (Stopwatch.GetTimestamp() - start < 3 * Stopwatch.Frequency); + } + catch (Exception error) { failures[worker] = error.ToString(); cancel.Cancel(); throw; } + } + Exception? executionFailure = null, captureFailure = null; + BenchmarkDaemonHost.TimingSnapshot? after = null; + Task[] workers = Enumerable.Range(0, Workers).Select(Worker).ToArray(); + try { await Task.WhenAll(workers); } + catch (Exception error) + { + executionFailure = error; + cancel.Cancel(); + try { await Task.WhenAll(workers); } catch { /* Preserve all per-worker failures. */ } + } + finally + { + end = Stopwatch.GetTimestamp(); + TimelineEventSource.Log.Phase(phase, api, kind, "end", end, completed.Sum()); + try { after = await host.CaptureTimingAsync(ct); cpu.ObserveServer(after.ServerCpuMs); } + catch (Exception error) { captureFailure = error; } + } + var result = new TimelineUntimed(phase, api, "Stream", kind, Workers, kind == "warmup" ? Warmup : null, + kind == "settling" ? 3 : null, start, end, completed.Sum(), completed, failures, before, after, + executionFailure?.ToString(), captureFailure?.ToString()); + Write(output, kind == "warmup" ? $"phase-{phase:D2}-{api}.warmup.json" : $"settling-{api}.json", result); + if (executionFailure is not null) throw new InvalidOperationException($"Timeline {kind} failed without retry.", executionFailure); + if (captureFailure is not null) throw new IOException($"Timeline {kind} snapshot failed.", captureFailure); + if (completed.Any(x => kind == "warmup" ? x != Warmup : x <= 0) || end <= start + || (kind == "settling" && end - start < 3 * Stopwatch.Frequency)) + throw new InvalidDataException($"Timeline {kind} operation counts/timestamps differ."); + ValidateTimelineFamily(after!); + ValidateWire(before.Http, after!.Http, after.Wire - before.Wire, new(api, "Stream"), completed.Sum()); + return result; + } + + private static async Task MeasureTimelineAsync(BenchmarkDaemonHost host, Group group, + int phase, string api, string output, TimingCpuLedger cpu, CancellationToken ct) + { + static long[][][] Buffers() => Enumerable.Range(0, Workers).Select(_ => + Enumerable.Range(0, TimelinePerWorker / TimelineChunkLength).Select(_ => new long[TimelineChunkLength]).ToArray()).ToArray(); + long[][][] starts = Buffers(), ends = Buffers(); + var completed = new int[Workers]; + var readyTicks = new long[Workers]; + var doneTicks = new long[Workers]; + var failures = new string?[Workers]; + var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int readyCount = 0; + using var cancel = CancellationTokenSource.CreateLinkedTokenSource(ct); + async Task Worker(int worker) + { + try + { + readyTicks[worker] = Stopwatch.GetTimestamp(); + if (Interlocked.Increment(ref readyCount) == Workers) ready.SetResult(); + await release.Task.WaitAsync(cancel.Token); + for (int n = 0; n < TimelinePerWorker; n++) + { + int chunk = n / TimelineChunkLength, offset = n % TimelineChunkLength; + starts[worker][chunk][offset] = Stopwatch.GetTimestamp(); + await group.Runners[worker].ReadAsync(cancel.Token); + ends[worker][chunk][offset] = Stopwatch.GetTimestamp(); + completed[worker]++; + } + } + catch (Exception error) { failures[worker] = error.ToString(); cancel.Cancel(); throw; } + finally { doneTicks[worker] = Stopwatch.GetTimestamp(); } + } + Task[] workers = Enumerable.Range(0, Workers).Select(Worker).ToArray(); + using var process = Process.GetCurrentProcess(); + BenchmarkDaemonHost.TimingSnapshot? before = null, after = null; + double? clientBefore = null, clientAfter = null; + long blockStart = 0, blockEnd = 0; + Exception? executionFailure = null, captureFailure = null, cpuFailure = null; + try + { + await ready.Task.WaitAsync(ct); + before = await host.CaptureTimingAsync(ct); + ValidateTimelineFamily(before); + clientBefore = process.TotalProcessorTime.TotalMilliseconds; + cpu.Observe(clientBefore.Value, before.ServerCpuMs); + blockStart = Stopwatch.GetTimestamp(); + TimelineEventSource.Log.Phase(phase, api, "measured", "start", blockStart, 0); + release.SetResult(); + await Task.WhenAll(workers); + blockEnd = Stopwatch.GetTimestamp(); + } + catch (Exception error) + { + executionFailure = error; + cancel.Cancel(); + try { await Task.WhenAll(workers); } catch { /* Keep the first error and every partial sample. */ } + } + finally + { + if (blockStart != 0 && blockEnd == 0) blockEnd = Stopwatch.GetTimestamp(); + if (blockStart != 0) TimelineEventSource.Log.Phase(phase, api, "measured", "end", blockEnd, completed.Sum()); + try { after = await host.CaptureTimingAsync(ct); clientAfter = process.TotalProcessorTime.TotalMilliseconds; } + catch (Exception error) { captureFailure = error; } + try + { + if (before is null || after is null || clientBefore is null || clientAfter is null) + throw new InvalidDataException("Timeline CPU snapshot missing."); + ValidateTimingCpu(clientBefore.Value, clientAfter.Value, before.ServerCpuMs, after.ServerCpuMs); + cpu.Observe(clientAfter.Value, after.ServerCpuMs); + } + catch (Exception error) { cpuFailure = error; } + bool validCpu = cpuFailure is null && captureFailure is null; + string stem = $"phase-{phase:D2}-{api}"; + using (var csv = new StreamWriter(Path.Combine(output, stem + ".csv"))) + { + csv.WriteLine("Worker,Sample,StartTicks,EndTicks,ElapsedTicks"); + for (int worker = 0; worker < Workers; worker++) + for (int n = 0; n < completed[worker]; n++) + { + long start = starts[worker][n / TimelineChunkLength][n % TimelineChunkLength]; + long end = ends[worker][n / TimelineChunkLength][n % TimelineChunkLength]; + csv.WriteLine(FormattableString.Invariant($"{worker + 1},{n + 1},{start},{end},{end - start}")); + } + } + Write(output, stem + ".metrics.json", new + { + Phase = phase, Api = api, Wire = "Stream", Kind = "measured", Frequency = Stopwatch.Frequency, + Workers, PerWorker = TimelinePerWorker, Requested = TimelineCount, Completed = completed.Sum(), + PerWorkerCompleted = completed, ReadyTicks = readyTicks, DoneTicks = doneTicks, + BlockStart = blockStart, BlockEnd = blockEnd, ElapsedTicks = blockEnd - blockStart, + Before = before, After = after, AllocationAccounting = "NotCollected", + ClientCpuBeforeMs = FiniteTimingCpu(clientBefore), ClientCpuAfterMs = FiniteTimingCpu(clientAfter), + ServerCpuBeforeMs = FiniteTimingCpu(before?.ServerCpuMs), ServerCpuAfterMs = FiniteTimingCpu(after?.ServerCpuMs), + ClientCpuMs = validCpu ? clientAfter - clientBefore : null, + ServerCpuMs = validCpu ? after!.ServerCpuMs - before!.ServerCpuMs : (double?)null, + CpuMs = validCpu ? clientAfter - clientBefore + after!.ServerCpuMs - before!.ServerCpuMs : null, + CpuAccountingValid = validCpu, CpuSnapshotsText = new + { + ClientBefore = clientBefore?.ToString("R", CultureInfo.InvariantCulture), ClientAfter = clientAfter?.ToString("R", CultureInfo.InvariantCulture), + ServerBefore = before?.ServerCpuMs.ToString("R", CultureInfo.InvariantCulture), ServerAfter = after?.ServerCpuMs.ToString("R", CultureInfo.InvariantCulture), + }, + WorkerFailures = failures, ExecutionFailure = executionFailure?.ToString(), + CaptureFailure = captureFailure?.ToString(), CpuFailure = cpuFailure?.ToString(), + }); + } + if (executionFailure is not null) throw new InvalidOperationException("Timeline phase failed; partial raw samples retained without retry.", executionFailure); + if (captureFailure is not null) throw new IOException("Timeline phase snapshot failed; partial evidence retained.", captureFailure); + if (cpuFailure is not null) throw new InvalidDataException("Timeline CPU accounting invalid; evidence retained.", cpuFailure); + if (completed.Any(x => x != TimelinePerWorker) || readyTicks.Any(x => x == 0 || x > blockStart) + || doneTicks.Any(x => x < blockStart || x > blockEnd) || blockEnd <= blockStart) + throw new InvalidDataException("Timeline worker counts/readiness/completion differ."); + for (int worker = 0; worker < Workers; worker++) + { + long previous = blockStart; + for (int n = 0; n < TimelinePerWorker; n++) + { + long start = starts[worker][n / TimelineChunkLength][n % TimelineChunkLength]; + long end = ends[worker][n / TimelineChunkLength][n % TimelineChunkLength]; + if (start < previous || end <= start || end > doneTicks[worker]) throw new InvalidDataException("Timeline sample outside ordered worker envelope."); + previous = end; + } + } + ValidateTimelineFamily(after!); + ValidateWire(before!.Http, after!.Http, after.Wire - before.Wire, new(api, "Stream"), TimelineCount); + double clientCpu = clientAfter!.Value - clientBefore!.Value, serverCpu = after.ServerCpuMs - before.ServerCpuMs; + return new(phase, api, "Stream", TimelineCount, blockStart, blockEnd, + TimelineCount * (double)Stopwatch.Frequency / (blockEnd - blockStart), clientCpu, serverCpu, clientCpu + serverCpu); + } + + private static void ValidateTimelineFamily(BenchmarkDaemonHost.TimingSnapshot snapshot) + { + if (!double.IsFinite(snapshot.ServerCpuMs) || snapshot.ServerCpuMs < 0) throw new InvalidDataException("Invalid timeline server CPU snapshot."); + long sessions = 0, seed = 0; + foreach (var row in snapshot.Http) + { + if (row.Value < 0 || row.Protocol != "HTTP/2" || !row.ContentType.StartsWith("application/grpc", StringComparison.Ordinal) + || row.ContentType.Contains("grpc-web", StringComparison.Ordinal)) throw new InvalidDataException("Unexpected timeline HTTP family."); + if (row.Method.EndsWith("/CommandSession", StringComparison.Ordinal)) sessions += row.Value; + else if (row.Method.EndsWith("/ExecuteSql", StringComparison.Ordinal)) seed += row.Value; + else throw new InvalidDataException("Unexpected timeline HTTP method."); + } + if (snapshot.Http.Length != 2 || sessions != 8 || seed != 1 || snapshot.Wire.UnarySql != 1 + || snapshot.Wire.UnaryOther != 0 || snapshot.Wire.StreamOther != 16 + || snapshot.Wire.StreamSql < 0 || snapshot.Wire.StreamResponses != snapshot.Wire.StreamSql + 16) + throw new InvalidDataException("Timeline requires exactly8 shared stream owners and one seed RPC."); + } + + private sealed record TimelineCollectors(int ClientProcessId, int ServerProcessId, string? State, + bool? Succeeded, string? Outcome, string[]? Errors); + private sealed record TimelineClockSync(int Id, long ClientBeforeQpc, long ClientAfterQpc, long ClientClockQpc, + long ServerQpc, long Frequency, int ClientProcessId, int ServerProcessId); + private sealed record TimelineUntimed(int Phase, string Api, string Wire, string Kind, int Workers, int? PerWorker, + int? SettlingSeconds, long StartQpc, long EndQpc, int Completed, int[] PerWorkerCompleted, string?[] WorkerFailures, + BenchmarkDaemonHost.TimingSnapshot Before, BenchmarkDaemonHost.TimingSnapshot? After, string? ExecutionFailure, string? CaptureFailure); + private sealed record TimelinePhase(int Phase, string Api, string Wire, int Completed, long BlockStart, long BlockEnd, + double OpsPerSecond, double ClientCpuMs, double ServerCpuMs, double CpuMs); +} + +[EventSource(Name = "CSharpDB-Baseline-Timeline")] +internal sealed class TimelineEventSource : EventSource +{ + internal static readonly TimelineEventSource Log = new(); + private TimelineEventSource() { } + + [Event(1, Level = EventLevel.Informational)] + public void Phase(int phase, string api, string kind, string edge, long qpc, long completed) + { + if (IsEnabled()) WriteEvent(1, phase, api, kind, edge, qpc, completed); + } + + [Event(2, Level = EventLevel.Informational)] + public void Clock(int id, long qpc, long frequency) + { + if (IsEnabled()) WriteEvent(2, new object[] { id, qpc, frequency }); + } +} diff --git a/tests/CSharpDB.NetworkBenchmarks/GrpcConcurrentSessionBenchmark.LongPairedTiming.cs b/tests/CSharpDB.NetworkBenchmarks/GrpcConcurrentSessionBenchmark.LongPairedTiming.cs new file mode 100644 index 00000000..a2dcadd9 --- /dev/null +++ b/tests/CSharpDB.NetworkBenchmarks/GrpcConcurrentSessionBenchmark.LongPairedTiming.cs @@ -0,0 +1,425 @@ +using System.Diagnostics; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text.Json; +using CSharpDB.Client; +using static CSharpDB.NetworkBenchmarks.GrpcSessionBenchmark; + +namespace CSharpDB.NetworkBenchmarks; + +internal static partial class GrpcConcurrentSessionBenchmark +{ + private const int LongPerWorker = 32768, LongCount = Workers * LongPerWorker, LongTimingChunkLength = 4096; + + // One fixed member of an identical-build A/A pair. The external validator + // assesses repeatability; this mode never retries or qualifies a product change. + public static async Task RunLongPairedTimingAsync(string[] args) + { + string? output = null; + bool ownsOutput = false; + try + { + string[] flags = ["--experiment", "--variant", "--round", "--output"]; + if (args.Length != 8 || args.Where((_, i) => i % 2 == 0).Distinct().Count() != flags.Length + || !flags.All(flag => args.Where((_, i) => i % 2 == 0).Contains(flag)) + || args[Array.IndexOf(args, "--experiment") + 1] != "session-c8-paired-long-timing") + throw new ArgumentException("Use --experiment session-c8-paired-long-timing --variant baseline|candidate --round 1..6 --output ; all workload settings are fixed."); + + string variant = args[Array.IndexOf(args, "--variant") + 1]; + if (variant is not ("baseline" or "candidate") + || !int.TryParse(args[Array.IndexOf(args, "--round") + 1], NumberStyles.None, CultureInfo.InvariantCulture, out int round) + || round is < 1 or > 6) + throw new ArgumentException("Invalid variant or round."); + + string root = Git("rev-parse", "--show-toplevel"); + output = Path.GetFullPath(args[Array.IndexOf(args, "--output") + 1]); + if (output.Equals(root, StringComparison.OrdinalIgnoreCase) + || output.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("Evidence must be outside the repository."); + if (Directory.Exists(output) || File.Exists(output)) throw new ArgumentException("Evidence directory must be new."); + Directory.CreateDirectory(output); + ownsOutput = true; + using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(3)); + await RunLongPairedTimingCoreAsync(root, output, variant, round, timeout.Token); + return 0; + } + catch (Exception error) + { + if (ownsOutput && output is not null) + { + File.AppendAllText(Path.Combine(output, "failures.jsonl"), JsonSerializer.Serialize(new { Utc = DateTimeOffset.UtcNow, Error = error.ToString() }) + Environment.NewLine); + Write(output, "status.json", new { State = "failed", AllocationAccounting = "NotCollected", FinishedUtc = DateTimeOffset.UtcNow }); + } + Console.Error.WriteLine(error); + return 1; + } + } + + private static async Task RunLongPairedTimingCoreAsync(string root, string output, string variant, int round, CancellationToken ct) + { + string frozenRoot = Directory.GetParent(Path.TrimEndingDirectorySeparator(AppContext.BaseDirectory))!.FullName; + string manifestPath = Path.Combine(frozenRoot, "snapshot.json"); + byte[] manifestBytes = File.ReadAllBytes(manifestPath); + string manifestSha256 = Convert.ToHexString(SHA256.HashData(manifestBytes)); + var snapshot = JsonSerializer.Deserialize(manifestBytes) + ?? throw new InvalidDataException("Missing frozen build manifest."); + if (snapshot.Variant != variant || snapshot.Sources.Length == 0 || snapshot.Binaries.Length == 0) + throw new InvalidDataException("Wrong frozen variant manifest."); + VerifyFrozenSources(frozenRoot, snapshot.Sources); + var binaries = BinaryHashes(); + if (!snapshot.Binaries.SequenceEqual(binaries)) throw new InvalidDataException("Frozen binary hash mismatch."); + var liveSources = SourceHashes(root); + string liveStatus = Git("status", "--porcelain=v1"); + string liveHead = Git("rev-parse", "HEAD"); + string[] apis = round is 2 or 3 or 6 ? ["ADO", "SDK"] : ["SDK", "ADO"]; + Write(output, "protocol.json", new + { + Version = 1, + Experiment = "matched-C8-long-timing", + Variant = variant, + Round = round, + ProfileOnly = false, + QualificationEligible = false, + AllocationAccounting = "NotCollected", + Workers, PerWorker = LongPerWorker, Count = LongCount, Rounds, Warmup, + TimingChunkLength = LongTimingChunkLength, + DeadlineSeconds = 180, + SettlingSecondsPerArm = 3, + PoolCapacity = Workers, + ApiOrder = apis, + Query = "SELECT value FROM network_read WHERE id = 1", + Scope = "Fixed longer-block identical-build A/A repeatability control. Baseline/candidate are labels for the same frozen sources and runtime. Separate client/server processes, actual daemon, warm cleartext loopback, one held rental/command per worker, one command at a time, same shared SDK-owned pool. No profiler, replay, exclusions or retries. No product improvement, TLS, remote-host, writes, pipelining or prepared-handle qualification.", + Order = "External six-pair schedule: baseline first in rounds1,4,5; candidate first in rounds2,3,6. Within each member ADO first in rounds2,3,6, SDK first otherwise. This process measures one member only.", + Timing = "All8 workers warm256 reads then signal readiness; asynchronous start barrier. Every block retains262144 validated scalar42 reads,32768 per worker, with actual per-read QPC start/end. Timing buffers use4096-element chunks. Wall time spans release through completion of all workers. Cancel siblings on failure, drain workers, preserve partial samples, stop. CSV writes, ordered-sample checks and bounded-merge nearest-rank quantiles occur after the window.", + Gate = "The external fixed six-pair A/A validator assesses each API: each label max/min throughput spread<=25%, absolute candidate/baseline median throughput change<=5%, absolute median pooled blockp99 change<=10%, absolute median worst-worker blockp99 change<=10%. Paired wins are diagnostic only. All raw counts, finite nonnegative monotonic CPU, wire families and provenance must validate. No optimization or product promotion is evaluated, and previous failed studies remain failed. No retries or exclusions.", + Validation = "Each of8 workers completes32768 reads per block. Two streaming blocks per member,524288 total reads;24 blocks across12 members,6291456 total. Every timed block has262144 SQL frames/responses and zero new HTTP RPCs. Before/after pool proof requires exactly8 retained CommandSession owners shared by SDK/ADO, zero GetInfo and16 other stream frames. Pool delta includes two2048-read warmups plus524288 timed reads. Frozen sources, manifest, binaries and live workspace must remain unchanged.", + Accounting = "Allocation counters are not collected in either client or daemon. Only timing CPU snapshots are used; the legacy allocation snapshot is never invoked. Bounded control-pipe/counter work is outside latency windows. No forced GC, stage probes, ThreadPool/GC/OS/runtime tuning or profiling.", + Provenance = "Frozen source and binary hashes identify this variant. LiveWorkspace is only a stability check and does not identify the baseline/candidate build sources. The exact manifest and frozen sources are copied alongside the samples.", + FrozenRoot = frozenRoot, + ManifestSha256 = manifestSha256, + Snapshot = snapshot, + Binaries = binaries, + LiveWorkspace = new { Root = root, GitHead = liveHead, GitStatus = liveStatus, Sources = liveSources }, + Stopwatch.Frequency, + Runtime = RuntimeInformation.FrameworkDescription, + OS = RuntimeInformation.OSDescription, + Environment.MachineName, + Environment.ProcessorCount, + System.Runtime.GCSettings.IsServerGC, + }); + File.WriteAllBytes(Path.Combine(output, "variant-manifest.json"), manifestBytes); + foreach (var file in snapshot.Sources) + { + string destination = Path.Combine(output, "source", file.Name); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + File.Copy(Path.Combine(frozenRoot, file.Name), destination); + } + Write(output, "status.json", new { State = "running", AllocationAccounting = "NotCollected", StartedUtc = DateTimeOffset.UtcNow }); + await using var host = await BenchmarkDaemonHost.StartAsync(true, null, ct); + Write(output, "host-info.json", host.Info); + var blocks = new List(); + var settling = new List(); + long settledOperations = 0; + var cpu = new TimingCpuLedger(); + try + { + await using (var seed = CSharpDbClient.Create(new() + { + Transport = CSharpDbTransport.Grpc, + GrpcMode = CSharpDbGrpcMode.Native, + Endpoint = host.NativeEndpoint.AbsoluteUri, + })) + { + var result = await seed.ExecuteSqlAsync("CREATE TABLE network_read (id INTEGER PRIMARY KEY, value INTEGER); INSERT INTO network_read VALUES (1, 42)", ct); + if (result.Error is not null) throw new InvalidOperationException(result.Error); + } + foreach (string api in apis) + { + await using var group = await Group.OpenAsync(host, new(api, "Stream"), ct); + var timer = Stopwatch.StartNew(); + var completed = new int[Workers]; + using var settleCancel = CancellationTokenSource.CreateLinkedTokenSource(ct); + try + { + await Task.WhenAll(group.Runners.Select(async (runner, worker) => + { + try + { + do { await runner.ReadAsync(settleCancel.Token); completed[worker]++; } + while (timer.Elapsed < TimeSpan.FromSeconds(3)); + } + catch { settleCancel.Cancel(); throw; } + })); + } + finally + { + settledOperations += completed.Sum(); + settling.Add(new { Api = api, Wire = "Stream", Completed = completed, ElapsedSeconds = timer.Elapsed.TotalSeconds }); + Write(output, "settling.json", settling); + } + } + var before = await host.CaptureTimingAsync(ct); + ValidateLongPairedTimingStreamFamily(before); + if (before.Wire.StreamSql != settledOperations) throw new InvalidDataException("Unexpected reads outside settling."); + cpu.ObserveServer(before.ServerCpuMs); + Write(output, "pool-proof-before.json", before); + foreach (string api in apis) + { + TimingBlock block = await MeasureLongTimingAsync(host, new(api, "Stream"), round, output, cpu, ct); + blocks.Add(block); + Write(output, "blocks.json", blocks); + Console.WriteLine($"{variant} {api} round{round}: {block.OpsPerSecond:N0} ops/sec; p99 {block.P99Us:N1} us; worst workerp99 {block.WorstWorkerP99Us:N1} us"); + } + var after = await host.CaptureTimingAsync(ct); + ValidateLongPairedTimingStreamFamily(after); + cpu.ObserveServer(after.ServerCpuMs); + Write(output, "pool-proof-after.json", after); + ValidateWire(before.Http, after.Http, after.Wire - before.Wire, new("SDK", "Stream"), 2 * (LongCount + Workers * Warmup)); + if (!liveSources.SequenceEqual(SourceHashes(root)) || liveStatus != Git("status", "--porcelain=v1") + || liveHead != Git("rev-parse", "HEAD") || !binaries.SequenceEqual(BinaryHashes()) + || manifestSha256 != Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(manifestPath)))) + throw new InvalidDataException("Live workspace, frozen manifest or frozen binaries changed during run."); + VerifyFrozenSources(frozenRoot, snapshot.Sources); + if (blocks.Count != apis.Length || blocks.Any(x => x.Completed != LongCount || !x.CpuAccountingValid)) + throw new InvalidDataException("Paired timing requires both fixed blocks and valid CPU accounting."); + Write(output, "summary.json", new + { + Variant = variant, + Round = round, + Blocks = blocks, + TotalOperations = blocks.Sum(x => x.Completed), + SourcesAndBinariesUnchanged = true, + WireCountsValidated = true, + ProfileOnly = false, + QualificationEligible = false, + AllocationAccounting = "NotCollected", + CpuAccountingValid = true, + }); + Write(output, "status.json", new + { + State = "complete", + FinishedUtc = DateTimeOffset.UtcNow, + Blocks = blocks.Count, + TotalOperations = blocks.Sum(x => x.Completed), + AllocationAccounting = "NotCollected", + CpuAccountingValid = true, + }); + } + finally + { + await CSharpDbClient.ClearPoolAsync(host.CompatibilityEndpoint.AbsoluteUri); + await CSharpDbClient.ClearPoolAsync(host.NativeEndpoint.AbsoluteUri); + } + } + + private static async Task MeasureLongTimingAsync(BenchmarkDaemonHost host, Arm arm, int round, + string output, TimingCpuLedger cpu, CancellationToken ct) + { + await using var group = await Group.OpenAsync(host, arm, ct); + await Task.WhenAll(group.Runners.Select(async runner => + { + for (int i = 0; i < Warmup; i++) await runner.ReadAsync(ct); + })); + static long[][][] Buffers() => Enumerable.Range(0, Workers).Select(_ => + Enumerable.Range(0, LongPerWorker / LongTimingChunkLength).Select(_ => new long[LongTimingChunkLength]).ToArray()).ToArray(); + long[][][] starts = Buffers(), ends = Buffers(); + var completed = new int[Workers]; + var readyTicks = new long[Workers]; + var doneTicks = new long[Workers]; + var failures = new string?[Workers]; + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int readyCount = 0; + using var cancel = CancellationTokenSource.CreateLinkedTokenSource(ct); + async Task Worker(int worker) + { + try + { + readyTicks[worker] = Stopwatch.GetTimestamp(); + if (Interlocked.Increment(ref readyCount) == Workers) ready.SetResult(); + await release.Task.WaitAsync(cancel.Token); + for (int n = 0; n < LongPerWorker; n++) + { + int chunk = n / LongTimingChunkLength, offset = n % LongTimingChunkLength; + starts[worker][chunk][offset] = Stopwatch.GetTimestamp(); + await group.Runners[worker].ReadAsync(cancel.Token); + ends[worker][chunk][offset] = Stopwatch.GetTimestamp(); + completed[worker]++; + } + } + catch (Exception error) { failures[worker] = error.ToString(); cancel.Cancel(); throw; } + finally { doneTicks[worker] = Stopwatch.GetTimestamp(); } + } + Task[] workers = Enumerable.Range(0, Workers).Select(Worker).ToArray(); + using var process = Process.GetCurrentProcess(); + BenchmarkDaemonHost.TimingSnapshot? before = null, after = null; + double? clientCpuBefore = null, clientCpuAfter = null; + long blockStart = 0, blockEnd = 0; + Exception? executionFailure = null, captureFailure = null, cpuFailure = null; + try + { + await ready.Task.WaitAsync(ct); + before = await host.CaptureTimingAsync(ct); + ValidateLongPairedTimingStreamFamily(before); + clientCpuBefore = process.TotalProcessorTime.TotalMilliseconds; + try { cpu.Observe(clientCpuBefore.Value, before.ServerCpuMs); } + catch (Exception error) { cpuFailure = error; throw; } + blockStart = Stopwatch.GetTimestamp(); + release.SetResult(); + await Task.WhenAll(workers); + blockEnd = Stopwatch.GetTimestamp(); + } + catch (Exception error) + { + executionFailure = error; + cancel.Cancel(); + try { await Task.WhenAll(workers); } catch { /* Preserve first failure and every worker's raw status. */ } + } + finally + { + if (blockStart != 0 && blockEnd == 0) blockEnd = Stopwatch.GetTimestamp(); + try + { + after = await host.CaptureTimingAsync(ct); + clientCpuAfter = process.TotalProcessorTime.TotalMilliseconds; + } + catch (Exception error) { captureFailure = error; } + try + { + if (before is null || after is null || clientCpuBefore is null || clientCpuAfter is null) + throw new InvalidDataException("Long timing CPU snapshot is missing."); + ValidateTimingCpu(clientCpuBefore.Value, clientCpuAfter.Value, before.ServerCpuMs, after.ServerCpuMs); + cpu.Observe(clientCpuAfter.Value, after.ServerCpuMs); + } + catch (Exception error) { cpuFailure ??= error; } + bool validCpu = cpuFailure is null && captureFailure is null; + string stem = $"{arm.Api}-{arm.Wire}-{round}"; + using (var csv = new StreamWriter(Path.Combine(output, stem + ".csv"))) + { + csv.WriteLine("Worker,Sample,StartTicks,EndTicks,ElapsedTicks"); + for (int worker = 0; worker < Workers; worker++) + for (int n = 0; n < completed[worker]; n++) + { + long start = starts[worker][n / LongTimingChunkLength][n % LongTimingChunkLength]; + long end = ends[worker][n / LongTimingChunkLength][n % LongTimingChunkLength]; + csv.WriteLine(FormattableString.Invariant($"{worker + 1},{n + 1},{start},{end},{end - start}")); + } + } + Write(output, stem + ".metrics.json", new + { + Completed = completed.Sum(), Requested = LongCount, PerWorker = completed, + ReadyTicks = readyTicks, DoneTicks = doneTicks, BlockStart = blockStart, BlockEnd = blockEnd, + ElapsedTicks = blockEnd - blockStart, + AllocationAccounting = "NotCollected", + ClientCpuBeforeMs = FiniteTimingCpu(clientCpuBefore), ClientCpuAfterMs = FiniteTimingCpu(clientCpuAfter), + ServerCpuBeforeMs = FiniteTimingCpu(before?.ServerCpuMs), ServerCpuAfterMs = FiniteTimingCpu(after?.ServerCpuMs), + ClientCpuMs = validCpu ? clientCpuAfter - clientCpuBefore : null, + ServerCpuMs = validCpu ? after!.ServerCpuMs - before!.ServerCpuMs : (double?)null, + CpuMs = validCpu ? clientCpuAfter - clientCpuBefore + after!.ServerCpuMs - before!.ServerCpuMs : null, + CpuAccountingValid = validCpu, + CpuSnapshotsText = new + { + ClientBefore = clientCpuBefore?.ToString("R", CultureInfo.InvariantCulture), + ClientAfter = clientCpuAfter?.ToString("R", CultureInfo.InvariantCulture), + ServerBefore = before?.ServerCpuMs.ToString("R", CultureInfo.InvariantCulture), + ServerAfter = after?.ServerCpuMs.ToString("R", CultureInfo.InvariantCulture), + }, + WorkerFailures = failures, ExecutionFailure = executionFailure?.ToString(), + CaptureFailure = captureFailure?.ToString(), CpuFailure = cpuFailure?.ToString(), + }); + Write(output, stem + ".wire.json", new { Before = before?.Wire, After = after?.Wire, HttpBefore = before?.Http, HttpAfter = after?.Http }); + } + if (executionFailure is not null) throw new InvalidOperationException("Long timing block failed; partial raw samples retained without retry.", executionFailure); + if (captureFailure is not null) throw new IOException("Long timing counter snapshot failed; partial raw samples retained.", captureFailure); + if (cpuFailure is not null) throw new InvalidDataException("Long timing CPU counters were invalid; raw snapshots retained without retry.", cpuFailure); + if (completed.Any(n => n != LongPerWorker) || readyTicks.Any(t => t == 0 || t > blockStart) + || doneTicks.Any(t => t < blockStart || t > blockEnd) || blockEnd <= blockStart) + throw new InvalidOperationException("Long timing worker readiness, timing or operation count mismatch."); + for (int worker = 0; worker < Workers; worker++) + { + long previousEnd = blockStart; + for (int n = 0; n < LongPerWorker; n++) + { + long start = starts[worker][n / LongTimingChunkLength][n % LongTimingChunkLength]; + long end = ends[worker][n / LongTimingChunkLength][n % LongTimingChunkLength]; + if (start < previousEnd || end < start || end > doneTicks[worker]) + throw new InvalidDataException("A long timing raw latency is outside its ordered worker envelope."); + previousEnd = end; + } + } + ValidateLongPairedTimingStreamFamily(after!); + ValidateWire(before!.Http, after!.Http, after.Wire - before.Wire, arm, LongCount); + // Raw CSV and envelope checks are complete. Reuse the start chunks as + // latency scratch; all sorts/merges run after the measured CPU window. + for (int worker = 0; worker < Workers; worker++) + for (int chunk = 0; chunk < starts[worker].Length; chunk++) + { + for (int n = 0; n < LongTimingChunkLength; n++) starts[worker][chunk][n] = ends[worker][chunk][n] - starts[worker][chunk][n]; + Array.Sort(starts[worker][chunk]); + } + var workerQuantiles = starts.Select(chunks => LongTimingQuantiles(chunks, LongPerWorker).P99).ToArray(); + var pooled = LongTimingQuantiles(starts.SelectMany(chunks => chunks).ToArray(), LongCount); + double clientCpu = clientCpuAfter!.Value - clientCpuBefore!.Value; + double serverCpu = after.ServerCpuMs - before.ServerCpuMs; + return new(arm.Api, arm.Wire, round, LongCount, LongCount * (double)Stopwatch.Frequency / (blockEnd - blockStart), + pooled.P50, pooled.P95, pooled.P99, workerQuantiles.Max(), clientCpu, serverCpu, clientCpu + serverCpu, true); + } + + private static LongTimingPercentiles LongTimingQuantiles(IReadOnlyList chunks, int count) + { + if (count <= 0 || chunks.Count == 0 || chunks.Any(x => x.Length == 0) || chunks.Sum(x => x.Length) != count) + throw new InvalidDataException("Invalid long timing quantile chunk counts."); + var queue = new PriorityQueue<(int Chunk, int Offset), long>(chunks.Count); + for (int chunk = 0; chunk < chunks.Count; chunk++) queue.Enqueue((chunk, 0), chunks[chunk][0]); + int rank50 = (int)Math.Ceiling(count * .50), rank95 = (int)Math.Ceiling(count * .95), rank99 = (int)Math.Ceiling(count * .99); + long p50 = 0, p95 = 0, p99 = 0; + for (int rank = 1; rank <= rank99; rank++) + { + if (!queue.TryDequeue(out var item, out long ticks)) throw new InvalidDataException("Truncated long timing quantile merge."); + if (rank == rank50) p50 = ticks; + if (rank == rank95) p95 = ticks; + if (rank == rank99) p99 = ticks; + int next = item.Offset + 1; + if (next < chunks[item.Chunk].Length) queue.Enqueue((item.Chunk, next), chunks[item.Chunk][next]); + } + return new(p50 * 1_000_000.0 / Stopwatch.Frequency, p95 * 1_000_000.0 / Stopwatch.Frequency, p99 * 1_000_000.0 / Stopwatch.Frequency); + } + + // Optional local reflection probe; never called by a measurement process. + public static object CheckLongTimingPercentileMerge() + { + long[][] vectors = + [ + [0], + Enumerable.Repeat(17L, 131).ToArray(), + Enumerable.Range(0, 257).Select(x => (long)(256 - x)).ToArray(), + Enumerable.Range(0, 391).Select(x => x % 9 == 0 ? 0L : x % 7 == 0 ? 123456789012L : x % 13).ToArray(), + ]; + var results = vectors.Select(vector => + { + var chunks = vector.Chunk(11).Select(chunk => chunk.Order().ToArray()).ToArray(); + var actual = LongTimingQuantiles(chunks, vector.Length); + var sorted = vector.Order().ToArray(); + var expected = new LongTimingPercentiles(Percentile(sorted, .50), Percentile(sorted, .95), Percentile(sorted, .99)); + if (actual != expected) throw new InvalidDataException("Long timing nearest-rank merge differs from the legacy percentile helper."); + return new { Samples = vector.Length, Chunks = chunks.Length, Actual = actual, Expected = expected, Passed = true }; + }).ToArray(); + return new { Passed = true, NearestRank = "Ceiling(count * quantile) - 1", Cases = results, NetworkOperations = 0 }; + } + + private readonly record struct LongTimingPercentiles(double P50, double P95, double P99); + + private static void ValidateLongPairedTimingStreamFamily(BenchmarkDaemonHost.TimingSnapshot snapshot) + { + long streams = snapshot.Http.Where(x => x.Method.EndsWith("/CommandSession", StringComparison.Ordinal)).Sum(x => x.Value); + long infos = snapshot.Http.Where(x => x.Method.EndsWith("/GetInfo", StringComparison.Ordinal)).Sum(x => x.Value); + long seed = snapshot.Http.Where(x => x.Method.EndsWith("/ExecuteSql", StringComparison.Ordinal)).Sum(x => x.Value); + if (snapshot.Http.Length != 2 || snapshot.Http.Any(x => x.Protocol != "HTTP/2" || x.Value < 0 + || !x.ContentType.StartsWith("application/grpc", StringComparison.Ordinal) || x.ContentType.Contains("grpc-web", StringComparison.Ordinal)) + || streams != Workers || seed != 1 || infos != 0 || snapshot.Wire.UnarySql != 1 || snapshot.Wire.UnaryOther != 0 + || snapshot.Wire.StreamOther != 2 * Workers || snapshot.Wire.StreamSql < 0 + || snapshot.Wire.StreamResponses != snapshot.Wire.StreamSql + 2 * Workers) + throw new InvalidDataException("Expected exactly eight retained streaming owners shared by SDK/ADO, without unary GetInfo."); + } +} diff --git a/tests/CSharpDB.NetworkBenchmarks/GrpcConcurrentSessionBenchmark.Paired.cs b/tests/CSharpDB.NetworkBenchmarks/GrpcConcurrentSessionBenchmark.Paired.cs new file mode 100644 index 00000000..09c115df --- /dev/null +++ b/tests/CSharpDB.NetworkBenchmarks/GrpcConcurrentSessionBenchmark.Paired.cs @@ -0,0 +1,131 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text.Json; +using CSharpDB.Client; +using static CSharpDB.NetworkBenchmarks.GrpcSessionBenchmark; + +namespace CSharpDB.NetworkBenchmarks; + +internal static partial class GrpcConcurrentSessionBenchmark +{ + // Each process measures one predeclared old/new pair member. The external schedule + // alternates variant order; source provenance comes from its frozen build, not the live checkout. + public static async Task RunPairedAsync(string[] args) + { + string? output = null; bool ownsOutput = false; + try + { + if (args.Length != 8 || args.Where((_, i) => i % 2 == 0).Distinct().Count() != 4 + || !new[] { "--experiment", "--variant", "--round", "--output" }.All(args.Contains)) + throw new ArgumentException("Use --experiment session-c8-paired --variant baseline|candidate --round 1..6 --output ."); + string variant = args[Array.IndexOf(args, "--variant") + 1]; + int round = int.Parse(args[Array.IndexOf(args, "--round") + 1]); + if (variant is not ("baseline" or "candidate") || round is < 1 or > 6) throw new ArgumentException("Invalid variant or round."); + string root = Git("rev-parse", "--show-toplevel"); + output = Path.GetFullPath(args[Array.IndexOf(args, "--output") + 1]); + if (output.Equals(root, StringComparison.OrdinalIgnoreCase) || output.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("Evidence must be outside the repository."); + if (Directory.Exists(output)) throw new ArgumentException("Evidence directory must be new."); + Directory.CreateDirectory(output); ownsOutput = true; + using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(2)); + await RunPairedCoreAsync(root, output, variant, round, timeout.Token); + return 0; + } + catch (Exception error) + { + if (ownsOutput && output is not null) + { + File.AppendAllText(Path.Combine(output, "failures.jsonl"), JsonSerializer.Serialize(new { Utc = DateTimeOffset.UtcNow, Error = error.ToString() }) + Environment.NewLine); + Write(output, "status.json", new { State = "failed", FinishedUtc = DateTimeOffset.UtcNow }); + } + Console.Error.WriteLine(error); return 1; + } + } + + private static async Task RunPairedCoreAsync(string root, string output, string variant, int round, CancellationToken ct) + { + string frozenRoot = Directory.GetParent(Path.TrimEndingDirectorySeparator(AppContext.BaseDirectory))!.FullName; + string manifestPath = Path.Combine(frozenRoot, "snapshot.json"); + var snapshot = JsonSerializer.Deserialize(File.ReadAllText(manifestPath)) + ?? throw new InvalidDataException("Missing frozen build manifest."); + if (snapshot.Variant != variant || snapshot.Sources.Length == 0 || snapshot.Binaries.Length == 0) + throw new InvalidDataException("Wrong frozen variant manifest."); + VerifyFrozenSources(frozenRoot, snapshot.Sources); + var binaries = BinaryHashes(); + if (!snapshot.Binaries.SequenceEqual(binaries)) throw new InvalidDataException("Frozen binary hash mismatch."); + var liveSources = SourceHashes(root); string liveStatus = Git("status", "--porcelain=v1"); + string[] apis = round is 2 or 3 or 6 ? ["ADO", "SDK"] : ["SDK", "ADO"]; + Write(output, "protocol.json", new + { + Version = 1, Experiment = "direct-dispatch-matched-C8", Variant = variant, Round = round, + Workers, PerWorker, Count, Warmup, SettlingSecondsPerArm = 3, PoolCapacity = Workers, + ApiOrder = apis, Query = "SELECT value FROM network_read WHERE id = 1", + Scope = "Diagnostic causal comparison of frozen old/new streaming builds. Separate client/server, warm cleartext loopback, one held rental/command per worker, same shared pool. No profiler, replay, exclusions or retries.", + Gate = "Global six-pair validator per API: both variants spread<=25%, candidate median throughput>=105% baseline,>=5/6 paired wins, median pooled p99 and median worst-worker p99 no worse. Require all reads/counts/accounting valid. Separate six-arm transport qualification is not a causal before/after comparison.", + Provenance = "Frozen sources and binary hashes identify this build; LiveWorkspace is only a stability check and does not identify baseline build sources.", + FrozenRoot = frozenRoot, ManifestSha256 = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(manifestPath))), + Snapshot = snapshot, Binaries = binaries, + LiveWorkspace = new { Root = root, GitHead = Git("rev-parse", "HEAD"), GitStatus = liveStatus, Sources = liveSources }, + Stopwatch.Frequency, Runtime = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription, + System.Runtime.GCSettings.IsServerGC, Environment.ProcessorCount, + }); + Write(output, "status.json", new { State = "running", StartedUtc = DateTimeOffset.UtcNow }); + await using var host = await BenchmarkDaemonHost.StartAsync(true, null, ct); + Write(output, "host-info.json", host.Info); + var blocks = new List(); var settling = new List(); + try + { + await using (var seed = CSharpDbClient.Create(new() { Transport = CSharpDbTransport.Grpc, GrpcMode = CSharpDbGrpcMode.Native, Endpoint = host.NativeEndpoint.AbsoluteUri })) + { + var result = await seed.ExecuteSqlAsync("CREATE TABLE network_read (id INTEGER PRIMARY KEY, value INTEGER); INSERT INTO network_read VALUES (1, 42)", ct); + if (result.Error is not null) throw new InvalidOperationException(result.Error); + } + foreach (string api in apis) + { + await using var group = await Group.OpenAsync(host, new(api, "Stream"), ct); + var timer = Stopwatch.StartNew(); var completed = new int[Workers]; + await Task.WhenAll(group.Runners.Select(async (runner, worker) => + { + do { await runner.ReadAsync(ct); completed[worker]++; } while (timer.Elapsed < TimeSpan.FromSeconds(3)); + })); + settling.Add(new { Api = api, Completed = completed, ElapsedSeconds = timer.Elapsed.TotalSeconds }); + Write(output, "settling.json", settling); + } + var before = await host.CaptureAsync(ct); ValidateStreamFamily(before); Write(output, "pool-proof-before.json", before); + foreach (string api in apis) + { + var block = await MeasureAsync(host, new(api, "Stream"), round, output, false, ct); + blocks.Add(block); Write(output, "blocks.json", blocks); + Console.WriteLine($"{variant} {api} round{round}: {block.OpsPerSecond:N0} ops/sec; p99 {block.P99Us:N1} us"); + } + var after = await host.CaptureAsync(ct); ValidateStreamFamily(after); Write(output, "pool-proof-after.json", after); + if (!liveSources.SequenceEqual(SourceHashes(root)) || liveStatus != Git("status", "--porcelain=v1") + || !binaries.SequenceEqual(BinaryHashes())) throw new InvalidDataException("Workspace or frozen binaries changed during run."); + VerifyFrozenSources(frozenRoot, snapshot.Sources); + Write(output, "summary.json", new { Variant = variant, Round = round, Blocks = blocks, TotalOperations = blocks.Sum(x => x.Completed), SourcesAndBinariesUnchanged = true, WireCountsValidated = true, ProfileOnly = false }); + Write(output, "status.json", new { State = "complete", FinishedUtc = DateTimeOffset.UtcNow, Blocks = blocks.Count, TotalOperations = blocks.Sum(x => x.Completed) }); + } + finally + { + await CSharpDbClient.ClearPoolAsync(host.CompatibilityEndpoint.AbsoluteUri); + await CSharpDbClient.ClearPoolAsync(host.NativeEndpoint.AbsoluteUri); + } + } + + private static void ValidateStreamFamily(BenchmarkDaemonHost.Snapshot snapshot) + { + long streams = snapshot.Http.Where(x => x.Method.EndsWith("/CommandSession", StringComparison.Ordinal)).Sum(x => x.Value); + long infos = snapshot.Http.Where(x => x.Method.EndsWith("/GetInfo", StringComparison.Ordinal)).Sum(x => x.Value); + if (streams != Workers || infos != 0 || snapshot.Wire.UnaryOther != 0 || snapshot.Wire.StreamOther != 2 * Workers) + throw new InvalidDataException("Expected exactly eight retained streaming owners shared by SDK/ADO, without unary GetInfo."); + } + + private static void VerifyFrozenSources(string root, Hash[] hashes) + { + foreach (var hash in hashes) + if (Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(Path.Combine(root, hash.Name)))) != hash.Sha256) + throw new InvalidDataException($"Frozen source changed: {hash.Name}"); + } + + private sealed record FrozenSnapshot(string Variant, string GitHead, string GitStatus, Hash[] Sources, Hash[] Binaries); +} diff --git a/tests/CSharpDB.NetworkBenchmarks/GrpcConcurrentSessionBenchmark.PairedTiming.cs b/tests/CSharpDB.NetworkBenchmarks/GrpcConcurrentSessionBenchmark.PairedTiming.cs new file mode 100644 index 00000000..33667ca2 --- /dev/null +++ b/tests/CSharpDB.NetworkBenchmarks/GrpcConcurrentSessionBenchmark.PairedTiming.cs @@ -0,0 +1,208 @@ +using System.Diagnostics; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text.Json; +using CSharpDB.Client; +using static CSharpDB.NetworkBenchmarks.GrpcSessionBenchmark; + +namespace CSharpDB.NetworkBenchmarks; + +internal static partial class GrpcConcurrentSessionBenchmark +{ + // One process measures one member of a predeclared old/new pair. The external + // six-pair schedule and validator decide causality; this mode never retries. + public static async Task RunPairedTimingAsync(string[] args) + { + string? output = null; + bool ownsOutput = false; + try + { + string[] flags = ["--experiment", "--variant", "--round", "--output"]; + if (args.Length != 8 || args.Where((_, i) => i % 2 == 0).Distinct().Count() != flags.Length + || !flags.All(flag => args.Where((_, i) => i % 2 == 0).Contains(flag)) + || args[Array.IndexOf(args, "--experiment") + 1] != "session-c8-paired-timing") + throw new ArgumentException("Use --experiment session-c8-paired-timing --variant baseline|candidate --round 1..6 --output ; all workload settings are fixed."); + + string variant = args[Array.IndexOf(args, "--variant") + 1]; + if (variant is not ("baseline" or "candidate") + || !int.TryParse(args[Array.IndexOf(args, "--round") + 1], NumberStyles.None, CultureInfo.InvariantCulture, out int round) + || round is < 1 or > 6) + throw new ArgumentException("Invalid variant or round."); + + string root = Git("rev-parse", "--show-toplevel"); + output = Path.GetFullPath(args[Array.IndexOf(args, "--output") + 1]); + if (output.Equals(root, StringComparison.OrdinalIgnoreCase) + || output.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("Evidence must be outside the repository."); + if (Directory.Exists(output)) throw new ArgumentException("Evidence directory must be new."); + Directory.CreateDirectory(output); + ownsOutput = true; + using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(2)); + await RunPairedTimingCoreAsync(root, output, variant, round, timeout.Token); + return 0; + } + catch (Exception error) + { + if (ownsOutput && output is not null) + { + File.AppendAllText(Path.Combine(output, "failures.jsonl"), JsonSerializer.Serialize(new { Utc = DateTimeOffset.UtcNow, Error = error.ToString() }) + Environment.NewLine); + Write(output, "status.json", new { State = "failed", AllocationAccounting = "NotCollected", FinishedUtc = DateTimeOffset.UtcNow }); + } + Console.Error.WriteLine(error); + return 1; + } + } + + private static async Task RunPairedTimingCoreAsync(string root, string output, string variant, int round, CancellationToken ct) + { + string frozenRoot = Directory.GetParent(Path.TrimEndingDirectorySeparator(AppContext.BaseDirectory))!.FullName; + string manifestPath = Path.Combine(frozenRoot, "snapshot.json"); + byte[] manifestBytes = File.ReadAllBytes(manifestPath); + string manifestSha256 = Convert.ToHexString(SHA256.HashData(manifestBytes)); + var snapshot = JsonSerializer.Deserialize(manifestBytes) + ?? throw new InvalidDataException("Missing frozen build manifest."); + if (snapshot.Variant != variant || snapshot.Sources.Length == 0 || snapshot.Binaries.Length == 0) + throw new InvalidDataException("Wrong frozen variant manifest."); + VerifyFrozenSources(frozenRoot, snapshot.Sources); + var binaries = BinaryHashes(); + if (!snapshot.Binaries.SequenceEqual(binaries)) throw new InvalidDataException("Frozen binary hash mismatch."); + var liveSources = SourceHashes(root); + string liveStatus = Git("status", "--porcelain=v1"); + string liveHead = Git("rev-parse", "HEAD"); + string[] apis = round is 2 or 3 or 6 ? ["ADO", "SDK"] : ["SDK", "ADO"]; + Write(output, "protocol.json", new + { + Version = 1, + Experiment = "matched-C8-timing", + Variant = variant, + Round = round, + ProfileOnly = false, + AllocationAccounting = "NotCollected", + Workers, PerWorker, Count, Rounds, Warmup, + SettlingSecondsPerArm = 3, + PoolCapacity = Workers, + ApiOrder = apis, + Query = "SELECT value FROM network_read WHERE id = 1", + Scope = "Causal comparison of frozen baseline/candidate streaming builds. Separate client/server processes, actual daemon, warm cleartext loopback, one held rental/command per worker, one command at a time, same shared SDK-owned pool. No profiler, replay, exclusions or retries. No TLS, remote-host, writes, pipelining or prepared-handle qualification.", + Order = "External six-pair schedule: baseline first in rounds1,4,5; candidate first in rounds2,3,6. Within each member ADO first in rounds2,3,6, SDK first otherwise. This process measures one member only.", + Timing = "All8 workers warm256 reads then signal readiness; asynchronous start barrier. Every block retains32768 validated scalar42 reads with per-worker/sample QPC start/end. Wall time spans release through completion of all workers. Cancel siblings on failure, drain workers, preserve partial samples, stop.", + Gate = "External six-pair validator per API: each variant max/min throughput spread<=25%, candidate median throughput>=105% baseline, >=5/6 paired wins, median pooled blockp99 and median worst-worker blockp99 no worse. Require finite nonnegative monotonic CPU, exact command/wire counts, unchanged source/binary provenance and zero errors. This member alone does not qualify a change. No retries after a failed gate.", + Validation = "Each of8 workers completes4096 reads per block. Two streaming blocks per member,65536 total reads. Every timed block has32768 SQL frames/responses and zero new HTTP RPCs. Before/after pool proof requires exactly8 retained CommandSession owners shared by SDK/ADO, zero GetInfo and16 other stream frames for hello/reset. Frozen sources, manifest, binaries and live workspace must remain unchanged.", + Accounting = "Allocation counters are not collected in either client or daemon. Only timing CPU snapshots are used; the legacy allocation snapshot is never invoked. Bounded control-pipe/counter work is outside latency windows. No forced GC, stage probes, ThreadPool/GC/OS/runtime tuning or profiling.", + Provenance = "Frozen source and binary hashes identify this variant. LiveWorkspace is only a stability check and does not identify the baseline/candidate build sources. The exact manifest and frozen sources are copied alongside the samples.", + FrozenRoot = frozenRoot, + ManifestSha256 = manifestSha256, + Snapshot = snapshot, + Binaries = binaries, + LiveWorkspace = new { Root = root, GitHead = liveHead, GitStatus = liveStatus, Sources = liveSources }, + Stopwatch.Frequency, + Runtime = RuntimeInformation.FrameworkDescription, + OS = RuntimeInformation.OSDescription, + Environment.MachineName, + Environment.ProcessorCount, + System.Runtime.GCSettings.IsServerGC, + }); + File.WriteAllBytes(Path.Combine(output, "variant-manifest.json"), manifestBytes); + foreach (var file in snapshot.Sources) + { + string destination = Path.Combine(output, "source", file.Name); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + File.Copy(Path.Combine(frozenRoot, file.Name), destination); + } + Write(output, "status.json", new { State = "running", AllocationAccounting = "NotCollected", StartedUtc = DateTimeOffset.UtcNow }); + await using var host = await BenchmarkDaemonHost.StartAsync(true, null, ct); + Write(output, "host-info.json", host.Info); + var blocks = new List(); + var settling = new List(); + var cpu = new TimingCpuLedger(); + try + { + await using (var seed = CSharpDbClient.Create(new() + { + Transport = CSharpDbTransport.Grpc, + GrpcMode = CSharpDbGrpcMode.Native, + Endpoint = host.NativeEndpoint.AbsoluteUri, + })) + { + var result = await seed.ExecuteSqlAsync("CREATE TABLE network_read (id INTEGER PRIMARY KEY, value INTEGER); INSERT INTO network_read VALUES (1, 42)", ct); + if (result.Error is not null) throw new InvalidOperationException(result.Error); + } + foreach (string api in apis) + { + await using var group = await Group.OpenAsync(host, new(api, "Stream"), ct); + var timer = Stopwatch.StartNew(); + var completed = new int[Workers]; + try + { + await Task.WhenAll(group.Runners.Select(async (runner, worker) => + { + do { await runner.ReadAsync(ct); completed[worker]++; } + while (timer.Elapsed < TimeSpan.FromSeconds(3)); + })); + } + finally + { + settling.Add(new { Api = api, Wire = "Stream", Completed = completed, ElapsedSeconds = timer.Elapsed.TotalSeconds }); + Write(output, "settling.json", settling); + } + } + var before = await host.CaptureTimingAsync(ct); + ValidatePairedTimingStreamFamily(before); + cpu.ObserveServer(before.ServerCpuMs); + Write(output, "pool-proof-before.json", before); + foreach (string api in apis) + { + TimingBlock block = await MeasureTimingAsync(host, new(api, "Stream"), round, output, cpu, ct); + blocks.Add(block); + Write(output, "blocks.json", blocks); + Console.WriteLine($"{variant} {api} round{round}: {block.OpsPerSecond:N0} ops/sec; p99 {block.P99Us:N1} us; worst workerp99 {block.WorstWorkerP99Us:N1} us"); + } + var after = await host.CaptureTimingAsync(ct); + ValidatePairedTimingStreamFamily(after); + cpu.ObserveServer(after.ServerCpuMs); + Write(output, "pool-proof-after.json", after); + if (!liveSources.SequenceEqual(SourceHashes(root)) || liveStatus != Git("status", "--porcelain=v1") + || liveHead != Git("rev-parse", "HEAD") || !binaries.SequenceEqual(BinaryHashes()) + || manifestSha256 != Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(manifestPath)))) + throw new InvalidDataException("Live workspace, frozen manifest or frozen binaries changed during run."); + VerifyFrozenSources(frozenRoot, snapshot.Sources); + if (blocks.Count != apis.Length || blocks.Any(x => x.Completed != Count || !x.CpuAccountingValid)) + throw new InvalidDataException("Paired timing requires both fixed blocks and valid CPU accounting."); + Write(output, "summary.json", new + { + Variant = variant, + Round = round, + Blocks = blocks, + TotalOperations = blocks.Sum(x => x.Completed), + SourcesAndBinariesUnchanged = true, + WireCountsValidated = true, + ProfileOnly = false, + AllocationAccounting = "NotCollected", + CpuAccountingValid = true, + }); + Write(output, "status.json", new + { + State = "complete", + FinishedUtc = DateTimeOffset.UtcNow, + Blocks = blocks.Count, + TotalOperations = blocks.Sum(x => x.Completed), + AllocationAccounting = "NotCollected", + CpuAccountingValid = true, + }); + } + finally + { + await CSharpDbClient.ClearPoolAsync(host.CompatibilityEndpoint.AbsoluteUri); + await CSharpDbClient.ClearPoolAsync(host.NativeEndpoint.AbsoluteUri); + } + } + + private static void ValidatePairedTimingStreamFamily(BenchmarkDaemonHost.TimingSnapshot snapshot) + { + long streams = snapshot.Http.Where(x => x.Method.EndsWith("/CommandSession", StringComparison.Ordinal)).Sum(x => x.Value); + long infos = snapshot.Http.Where(x => x.Method.EndsWith("/GetInfo", StringComparison.Ordinal)).Sum(x => x.Value); + if (streams != Workers || infos != 0 || snapshot.Wire.UnaryOther != 0 || snapshot.Wire.StreamOther != 2 * Workers) + throw new InvalidDataException("Expected exactly eight retained streaming owners shared by SDK/ADO, without unary GetInfo."); + } +} diff --git a/tests/CSharpDB.NetworkBenchmarks/GrpcConcurrentSessionBenchmark.Timing.cs b/tests/CSharpDB.NetworkBenchmarks/GrpcConcurrentSessionBenchmark.Timing.cs new file mode 100644 index 00000000..32254ab9 --- /dev/null +++ b/tests/CSharpDB.NetworkBenchmarks/GrpcConcurrentSessionBenchmark.Timing.cs @@ -0,0 +1,377 @@ +using System.Diagnostics; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Text.Json; +using CSharpDB.Client; +using static CSharpDB.NetworkBenchmarks.GrpcSessionBenchmark; + +namespace CSharpDB.NetworkBenchmarks; + +internal static partial class GrpcConcurrentSessionBenchmark +{ + public static async Task RunTimingAsync(string[] args, bool profileOnly = false) + { + string? output = null; + bool ownsOutput = false; + try + { + if (args.Length != 4 || Array.IndexOf(args, "--output") is not (0 or 2) + || Array.IndexOf(args, "--experiment") is not (0 or 2) + || args[Array.IndexOf(args, "--experiment") + 1] != (profileOnly ? "session-c8-timing-profile" : "session-c8-timing")) + throw new ArgumentException("Use --experiment session-c8-timing or session-c8-timing-profile --output ; all workload settings are fixed."); + output = Path.GetFullPath(args[Array.IndexOf(args, "--output") + 1]); + string root = Git("rev-parse", "--show-toplevel"); + if (output.Equals(root, StringComparison.OrdinalIgnoreCase) + || output.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("Keep generated evidence outside the repository."); + if (Directory.Exists(output)) throw new ArgumentException("Evidence directory must be new."); + Directory.CreateDirectory(output); + ownsOutput = true; + using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(5)); + await RunTimingCoreAsync(root, output, profileOnly, timeout.Token); + return 0; + } + catch (Exception error) + { + if (ownsOutput && output is not null) + { + File.AppendAllText(Path.Combine(output, "failures.jsonl"), JsonSerializer.Serialize(new { Utc = DateTimeOffset.UtcNow, Error = error.ToString() }) + Environment.NewLine); + Write(output, "status.json", new { State = "failed", AllocationAccounting = "NotCollected", FinishedUtc = DateTimeOffset.UtcNow }); + } + Console.Error.WriteLine(error); + return 1; + } + } + + private static async Task RunTimingCoreAsync(string root, string output, bool profileOnly, CancellationToken ct) + { + var sources = SourceHashes(root); + var binaries = BinaryHashes(); + string status = Git("status", "--porcelain=v1"); + Write(output, "protocol.json", new + { + Version = 3, + Experiment = profileOnly ? "persistent-command-session-isolated-C8-timing-profile" : "persistent-command-session-isolated-C8-timing", + ProfileOnly = profileOnly, + DiagnosticBoundary = profileOnly ? "Diagnostic trace only. Throughput gates cannot qualify or replace a failed run." : null, + AllocationAccounting = "NotCollected", + Workers, PerWorker, Count, Rounds, Warmup, + SettlingSecondsPerArm = 3, + PoolCapacity = Workers, + Query = "SELECT value FROM network_read WHERE id = 1", + Arms, + Order = "Six Williams rounds, base indices0,1,5,2,4,3 shifted by round modulo6. Every arm occupies every position once. No retries/exclusions.", + Scope = "Separate client/server processes, actual daemon, cleartext loopback, warm point reads. Eight held rentals/commands, one command at a time per worker; shared SDK-owned pool capacity8 per mode. No TLS, remote-host, writes, pipelining or prepared-handle qualification.", + Timing = "All8 workers warm then signal readiness; asynchronous start barrier. Wall time is release through completion of all workers. Latency spans every validated scalar42 read. Every worker/sample QPC start/end is retained. Cancel siblings on failure, drain workers, preserve partial samples, stop.", + Gate = "Per API all3 arms must pass max/min block throughput spread<=25%. Against EACH baseline stream median throughput>=105%, paired wins>=5/6, median pooled blockp99<=baseline AND median worst-worker blockp99<=baseline. Require finite nonnegative monotonic client/server CPU snapshots, exact command/wire counts and zero errors. An API qualifies only if both baseline comparisons pass. No retry after failed gate.", + Validation = "Each of8 workers completes4096 reads. Every timed block has32768 ExecuteSql requests/responses; no GetInfo/hello or extra HTTP calls. Streaming has32768 decoded SQL frames/responses and zero new HTTP RPCs. Before/after pool proof requires Web GetInfo8, native GetInfo8 and CommandSession8 shared by SDK/ADO. Source/binary hashes and working-tree state must remain unchanged.", + Accounting = profileOnly + ? "Diagnostic trace attached; no throughput qualification. Allocation counters are not collected in either client or daemon; no legacy allocation snapshot. CPU snapshots include bounded control-pipe work outside latency windows. No forced GC or ThreadPool/GC/OS/runtime tuning." + : "Allocation counters are not collected in either client or daemon. Timing mode never invokes the legacy allocation snapshot. Process CPU snapshots include bounded control-pipe/counter overhead outside the latency window; require monotonic snapshots within and across blocks. No profiler, forced GC, stage probes or ThreadPool/GC/OS/runtime tuning.", + Runtime = RuntimeInformation.FrameworkDescription, + OS = RuntimeInformation.OSDescription, + Environment.MachineName, + Environment.ProcessorCount, + System.Runtime.GCSettings.IsServerGC, + Stopwatch.Frequency, + GitHead = Git("rev-parse", "HEAD"), + GitStatus = status, + Sources = sources, + Binaries = binaries, + }); + File.WriteAllText(Path.Combine(output, "source.patch"), Git("diff", "--binary")); + foreach (var file in sources) + { + string destination = Path.Combine(output, "source", file.Name); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + File.Copy(Path.Combine(root, file.Name), destination); + } + Write(output, "status.json", new { State = "running", AllocationAccounting = "NotCollected", StartedUtc = DateTimeOffset.UtcNow }); + await using var host = await BenchmarkDaemonHost.StartAsync(true, null, ct); + Write(output, "host-info.json", host.Info); + var blocks = new List(); + var settling = new List(); + var cpu = new TimingCpuLedger(); + try + { + await using (var seed = CSharpDbClient.Create(new() + { + Transport = CSharpDbTransport.Grpc, + GrpcMode = CSharpDbGrpcMode.Native, + Endpoint = host.NativeEndpoint.AbsoluteUri, + })) + { + var result = await seed.ExecuteSqlAsync("CREATE TABLE network_read (id INTEGER PRIMARY KEY, value INTEGER); INSERT INTO network_read VALUES (1, 42)", ct); + if (result.Error is not null) throw new InvalidOperationException(result.Error); + } + foreach (var arm in Arms) + { + await using var group = await Group.OpenAsync(host, arm, ct); + var timer = Stopwatch.StartNew(); + var completed = new int[Workers]; + try + { + await Task.WhenAll(group.Runners.Select(async (runner, worker) => + { + do { await runner.ReadAsync(ct); completed[worker]++; } + while (timer.Elapsed < TimeSpan.FromSeconds(3)); + })); + } + finally + { + settling.Add(new { arm.Api, arm.Wire, Completed = completed, ElapsedSeconds = timer.Elapsed.TotalSeconds }); + Write(output, "settling.json", settling); + } + } + var settled = await host.CaptureTimingAsync(ct); + ValidateTimingFamilies(settled); + cpu.ObserveServer(settled.ServerCpuMs); + Write(output, "pool-proof-before.json", settled); + int[] order = [0, 1, 5, 2, 4, 3]; + for (int round = 0; round < Rounds; round++) + { + foreach (int position in order) + { + TimingBlock block = await MeasureTimingAsync(host, Arms[(position + round) % Arms.Length], round + 1, output, cpu, ct); + blocks.Add(block); + Write(output, "blocks.json", blocks); + Console.WriteLine($"{block.Api,-3} {block.Wire,-6} round{block.Round}: {block.OpsPerSecond:N0} ops/sec; p99 {block.P99Us:N1} us; worst workerp99 {block.WorstWorkerP99Us:N1} us"); + } + } + var final = await host.CaptureTimingAsync(ct); + ValidateTimingFamilies(final); + cpu.ObserveServer(final.ServerCpuMs); + Write(output, "pool-proof-after.json", final); + if (!sources.SequenceEqual(SourceHashes(root)) || !binaries.SequenceEqual(BinaryHashes()) + || status != Git("status", "--porcelain=v1")) + throw new InvalidOperationException("Source, binary, or working-tree state changed during the run."); + if (blocks.Count != Rounds * Arms.Length || blocks.Any(x => x.Completed != Count || !x.CpuAccountingValid)) + throw new InvalidDataException("Timing qualification requires every fixed block and valid CPU accounting."); + var summaries = blocks.GroupBy(x => new Arm(x.Api, x.Wire)).Select(g => new TimingSummary(g.Key.Api, g.Key.Wire, + Median(g.Select(x => x.OpsPerSecond)), Median(g.Select(x => x.P99Us)), Median(g.Select(x => x.WorstWorkerP99Us)), + g.Max(x => x.OpsPerSecond) / g.Min(x => x.OpsPerSecond) - 1, + Median(g.Select(x => x.ClientCpuMs)), Median(g.Select(x => x.ServerCpuMs)), g.All(x => x.CpuAccountingValid))).ToArray(); + var gates = new List(); + foreach (string api in new[] { "SDK", "ADO" }) + { + var stream = summaries.Single(x => x.Api == api && x.Wire == "Stream"); + bool stable = summaries.Where(x => x.Api == api).All(x => x.Spread <= .25); + bool validCpu = summaries.Where(x => x.Api == api).All(x => x.CpuAccountingValid); + foreach (string baseline in new[] { "Web", "Native" }) + { + var other = summaries.Single(x => x.Api == api && x.Wire == baseline); + int wins = blocks.Where(x => x.Api == api && x.Wire == "Stream").Count(x => x.OpsPerSecond + > blocks.Single(b => b.Api == api && b.Wire == baseline && b.Round == x.Round).OpsPerSecond); + double gain = stream.MedianOpsPerSecond / other.MedianOpsPerSecond - 1; + bool passed = !profileOnly && stable && validCpu && gain >= .05 && wins >= 5 + && stream.MedianP99Us <= other.MedianP99Us && stream.MedianWorstWorkerP99Us <= other.MedianWorstWorkerP99Us; + gates.Add(new(api, baseline, gain, wins, stable, stream.MedianP99Us / other.MedianP99Us, + stream.MedianWorstWorkerP99Us / other.MedianWorstWorkerP99Us, validCpu, passed)); + } + } + Write(output, "summary.json", new + { + Summaries = summaries, Gates = gates, + ApiOutcomes = new[] { "SDK", "ADO" }.Select(api => new { Api = api, Qualified = gates.Where(x => x.Api == api).All(x => x.Passed) }), + TotalOperations = blocks.Sum(x => x.Completed), + SourcesAndBinariesUnchanged = true, + WireCountsValidated = true, + ProfileOnly = profileOnly, + AllocationAccounting = "NotCollected", + CpuAccountingValid = true, + }); + Write(output, "status.json", new { State = "complete", FinishedUtc = DateTimeOffset.UtcNow, Blocks = blocks.Count, + TotalOperations = blocks.Sum(x => x.Completed), AllocationAccounting = "NotCollected", CpuAccountingValid = true }); + } + finally + { + await CSharpDbClient.ClearPoolAsync(host.CompatibilityEndpoint.AbsoluteUri); + await CSharpDbClient.ClearPoolAsync(host.NativeEndpoint.AbsoluteUri); + } + } + + private static async Task MeasureTimingAsync(BenchmarkDaemonHost host, Arm arm, int round, + string output, TimingCpuLedger cpu, CancellationToken ct) + { + await using var group = await Group.OpenAsync(host, arm, ct); + await Task.WhenAll(group.Runners.Select(async runner => + { + for (int i = 0; i < Warmup; i++) await runner.ReadAsync(ct); + })); + long[][] starts = Enumerable.Range(0, Workers).Select(_ => new long[PerWorker]).ToArray(); + long[][] ends = Enumerable.Range(0, Workers).Select(_ => new long[PerWorker]).ToArray(); + var completed = new int[Workers]; + var readyTicks = new long[Workers]; + var doneTicks = new long[Workers]; + var failures = new string?[Workers]; + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int readyCount = 0; + using var cancel = CancellationTokenSource.CreateLinkedTokenSource(ct); + async Task Worker(int worker) + { + try + { + readyTicks[worker] = Stopwatch.GetTimestamp(); + if (Interlocked.Increment(ref readyCount) == Workers) ready.SetResult(); + await release.Task.WaitAsync(cancel.Token); + for (int n = 0; n < PerWorker; n++) + { + starts[worker][n] = Stopwatch.GetTimestamp(); + await group.Runners[worker].ReadAsync(cancel.Token); + ends[worker][n] = Stopwatch.GetTimestamp(); + completed[worker]++; + } + } + catch (Exception error) { failures[worker] = error.ToString(); cancel.Cancel(); throw; } + finally { doneTicks[worker] = Stopwatch.GetTimestamp(); } + } + Task[] workers = Enumerable.Range(0, Workers).Select(Worker).ToArray(); + using var process = Process.GetCurrentProcess(); + BenchmarkDaemonHost.TimingSnapshot? before = null, after = null; + double? clientCpuBefore = null, clientCpuAfter = null; + long blockStart = 0, blockEnd = 0; + Exception? executionFailure = null, captureFailure = null, cpuFailure = null; + try + { + await ready.Task.WaitAsync(ct); + before = await host.CaptureTimingAsync(ct); + clientCpuBefore = process.TotalProcessorTime.TotalMilliseconds; + try { cpu.Observe(clientCpuBefore.Value, before.ServerCpuMs); } + catch (Exception error) { cpuFailure = error; throw; } + blockStart = Stopwatch.GetTimestamp(); + release.SetResult(); + await Task.WhenAll(workers); + blockEnd = Stopwatch.GetTimestamp(); + } + catch (Exception error) + { + executionFailure = error; + cancel.Cancel(); + try { await Task.WhenAll(workers); } catch { /* Preserve the first failure and every worker's raw status. */ } + } + finally + { + if (blockStart != 0 && blockEnd == 0) blockEnd = Stopwatch.GetTimestamp(); + try + { + after = await host.CaptureTimingAsync(ct); + clientCpuAfter = process.TotalProcessorTime.TotalMilliseconds; + } + catch (Exception error) { captureFailure = error; } + try + { + if (before is null || after is null || clientCpuBefore is null || clientCpuAfter is null) + throw new InvalidDataException("Timing CPU snapshot is missing."); + ValidateTimingCpu(clientCpuBefore.Value, clientCpuAfter.Value, before.ServerCpuMs, after.ServerCpuMs); + cpu.Observe(clientCpuAfter.Value, after.ServerCpuMs); + } + catch (Exception error) { cpuFailure ??= error; } + bool validCpu = cpuFailure is null && captureFailure is null; + string stem = $"{arm.Api}-{arm.Wire}-{round}"; + File.WriteAllLines(Path.Combine(output, stem + ".csv"), new[] { "Worker,Sample,StartTicks,EndTicks,ElapsedTicks" }.Concat( + Enumerable.Range(0, Workers).SelectMany(w => Enumerable.Range(0, completed[w]) + .Select(n => $"{w + 1},{n + 1},{starts[w][n]},{ends[w][n]},{ends[w][n] - starts[w][n]}")))); + Write(output, stem + ".metrics.json", new + { + Completed = completed.Sum(), Requested = Count, PerWorker = completed, + ReadyTicks = readyTicks, DoneTicks = doneTicks, BlockStart = blockStart, BlockEnd = blockEnd, + ElapsedTicks = blockEnd - blockStart, + AllocationAccounting = "NotCollected", + ClientCpuBeforeMs = FiniteTimingCpu(clientCpuBefore), ClientCpuAfterMs = FiniteTimingCpu(clientCpuAfter), + ServerCpuBeforeMs = FiniteTimingCpu(before?.ServerCpuMs), ServerCpuAfterMs = FiniteTimingCpu(after?.ServerCpuMs), + ClientCpuMs = validCpu ? clientCpuAfter - clientCpuBefore : null, + ServerCpuMs = validCpu ? after!.ServerCpuMs - before!.ServerCpuMs : (double?)null, + CpuMs = validCpu ? clientCpuAfter - clientCpuBefore + after!.ServerCpuMs - before!.ServerCpuMs : null, + CpuAccountingValid = validCpu, + CpuSnapshotsText = new + { + ClientBefore = clientCpuBefore?.ToString("R", CultureInfo.InvariantCulture), + ClientAfter = clientCpuAfter?.ToString("R", CultureInfo.InvariantCulture), + ServerBefore = before?.ServerCpuMs.ToString("R", CultureInfo.InvariantCulture), + ServerAfter = after?.ServerCpuMs.ToString("R", CultureInfo.InvariantCulture), + }, + WorkerFailures = failures, ExecutionFailure = executionFailure?.ToString(), + CaptureFailure = captureFailure?.ToString(), CpuFailure = cpuFailure?.ToString(), + }); + Write(output, stem + ".wire.json", new { Before = before?.Wire, After = after?.Wire, HttpBefore = before?.Http, HttpAfter = after?.Http }); + } + if (executionFailure is not null) throw new InvalidOperationException("Timing block failed; partial raw samples retained without retry.", executionFailure); + if (captureFailure is not null) throw new IOException("Timing counter snapshot failed; partial raw samples retained.", captureFailure); + if (cpuFailure is not null) throw new InvalidDataException("CPU counters decreased or were invalid; raw snapshots retained. Qualification stopped without retry.", cpuFailure); + if (completed.Any(n => n != PerWorker) || readyTicks.Any(t => t == 0 || t > blockStart) + || doneTicks.Any(t => t < blockStart || t > blockEnd) || blockEnd <= blockStart) + throw new InvalidOperationException("Worker readiness, timing or operation count mismatch."); + for (int worker = 0; worker < Workers; worker++) + { + for (int n = 0; n < PerWorker; n++) + { + if (starts[worker][n] < blockStart || ends[worker][n] < starts[worker][n] + || ends[worker][n] > blockEnd || (n > 0 && starts[worker][n] < ends[worker][n - 1])) + throw new InvalidDataException("A raw command latency is outside its ordered timing block."); + } + } + ValidateWire(before!.Http, after!.Http, after.Wire - before.Wire, arm, Count); + var workerQuantiles = Enumerable.Range(0, Workers).Select(w => + { + long[] ticks = Enumerable.Range(0, PerWorker).Select(n => ends[w][n] - starts[w][n]).Order().ToArray(); + return Percentile(ticks, .99); + }).ToArray(); + long[] all = Enumerable.Range(0, Workers).SelectMany(w => Enumerable.Range(0, PerWorker) + .Select(n => ends[w][n] - starts[w][n])).Order().ToArray(); + double clientCpu = clientCpuAfter!.Value - clientCpuBefore!.Value; + double serverCpu = after.ServerCpuMs - before.ServerCpuMs; + return new(arm.Api, arm.Wire, round, Count, Count * (double)Stopwatch.Frequency / (blockEnd - blockStart), + Percentile(all, .50), Percentile(all, .95), Percentile(all, .99), workerQuantiles.Max(), clientCpu, serverCpu, clientCpu + serverCpu, true); + } + + private static void ValidateTimingFamilies(BenchmarkDaemonHost.TimingSnapshot snapshot) + { + long web = snapshot.Http.Where(x => x.Method.EndsWith("/GetInfo", StringComparison.Ordinal) && x.Protocol == "HTTP/1.1").Sum(x => x.Value); + long native = snapshot.Http.Where(x => x.Method.EndsWith("/GetInfo", StringComparison.Ordinal) && x.Protocol == "HTTP/2").Sum(x => x.Value); + long streams = snapshot.Http.Where(x => x.Method.EndsWith("/CommandSession", StringComparison.Ordinal)).Sum(x => x.Value); + if (web != Workers || native != Workers || streams != Workers + || snapshot.Wire.UnaryOther != 2 * Workers || snapshot.Wire.StreamOther != 2 * Workers) + throw new InvalidOperationException($"Expected exactly8 retained physical owners per shared pool family: Web{web}, Native{native}, Stream{streams}."); + } + + private static void ValidateTimingCpu(double clientBefore, double clientAfter, double serverBefore, double serverAfter) + { + if (!double.IsFinite(clientBefore) || clientBefore < 0 || !double.IsFinite(clientAfter) || clientAfter < clientBefore + || !double.IsFinite(serverBefore) || serverBefore < 0 || !double.IsFinite(serverAfter) || serverAfter < serverBefore) + throw new InvalidDataException("CPU snapshots must be finite, nonnegative and monotonic."); + } + + private static double? FiniteTimingCpu(double? value) + => value.HasValue && double.IsFinite(value.Value) ? value : null; + + private sealed class TimingCpuLedger + { + private double _client, _server; + internal void Observe(double client, double server) + { + ValidateTimingCpu(_client, client, _server, server); + _client = client; + _server = server; + } + internal void ObserveServer(double server) + { + ValidateTimingCpu(_client, _client, _server, server); + _server = server; + } + } + + private sealed record TimingBlock(string Api, string Wire, int Round, int Completed, double OpsPerSecond, + double P50Us, double P95Us, double P99Us, double WorstWorkerP99Us, + double ClientCpuMs, double ServerCpuMs, double CpuMs, bool CpuAccountingValid) + { + public string AllocationAccounting => "NotCollected"; + } + private sealed record TimingSummary(string Api, string Wire, double MedianOpsPerSecond, double MedianP99Us, + double MedianWorstWorkerP99Us, double Spread, double MedianClientCpuMs, double MedianServerCpuMs, bool CpuAccountingValid) + { + public string AllocationAccounting => "NotCollected"; + } + private sealed record TimingGate(string Api, string Baseline, double ThroughputChange, int PairedWins, + bool AllApiArmsStable, double P99Ratio, double WorstWorkerP99Ratio, bool CpuAccountingValid, bool Passed); +} diff --git a/tests/CSharpDB.NetworkBenchmarks/GrpcConcurrentSessionBenchmark.cs b/tests/CSharpDB.NetworkBenchmarks/GrpcConcurrentSessionBenchmark.cs new file mode 100644 index 00000000..ac9ea281 --- /dev/null +++ b/tests/CSharpDB.NetworkBenchmarks/GrpcConcurrentSessionBenchmark.cs @@ -0,0 +1,263 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text.Json; +using CSharpDB.Client; +using static CSharpDB.NetworkBenchmarks.GrpcSessionBenchmark; + +namespace CSharpDB.NetworkBenchmarks; + +/// Fixed C8 qualification: one held rental and command per worker, no replay. +internal static partial class GrpcConcurrentSessionBenchmark +{ + private const int Workers = 8, PerWorker = 4096, Count = Workers * PerWorker, Rounds = 6, Warmup = 256; + private static readonly Arm[] Arms = [new("SDK", "Web"), new("SDK", "Native"), new("SDK", "Stream"), + new("ADO", "Web"), new("ADO", "Native"), new("ADO", "Stream")]; + + public static async Task RunAsync(string[] args, bool profileOnly = false) + { + string? output = null; + bool ownsOutput = false; + try + { + if (args.Length != 4 || Array.IndexOf(args, "--output") is not (0 or 2) || Array.IndexOf(args, "--experiment") is not (0 or 2)) + throw new ArgumentException("Use --experiment session-c8 --output ; all workload settings are fixed."); + output = Path.GetFullPath(args[Array.IndexOf(args, "--output") + 1]); + string root = Git("rev-parse", "--show-toplevel"); + if (output.Equals(root, StringComparison.OrdinalIgnoreCase) || output.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("Keep generated evidence outside the repository."); + if (Directory.Exists(output)) throw new ArgumentException("Evidence directory must be new."); + Directory.CreateDirectory(output); ownsOutput = true; + using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(5)); + await RunCoreAsync(root, output, profileOnly, timeout.Token); + return 0; + } + catch (Exception error) + { + if (ownsOutput && output is not null) + { + File.AppendAllText(Path.Combine(output, "failures.jsonl"), JsonSerializer.Serialize(new { Utc = DateTimeOffset.UtcNow, Error = error.ToString() }) + Environment.NewLine); + Write(output, "status.json", new { State = "failed", FinishedUtc = DateTimeOffset.UtcNow }); + } + Console.Error.WriteLine(error); return 1; + } + } + + private static async Task RunCoreAsync(string root, string output, bool profileOnly, CancellationToken ct) + { + var sources = SourceHashes(root); var binaries = BinaryHashes(); string status = Git("status", "--porcelain=v1"); + Write(output, "protocol.json", new + { + Version = profileOnly ? 2 : 1, + Experiment = profileOnly ? "persistent-command-session-isolated-C8-profile" : "persistent-command-session-isolated-C8", + ProfileOnly = profileOnly, + ProfileScope = profileOnly ? "DIAGNOSTIC ONLY. Parent client sampled managed/external stacks and allocation ticks with custom measured-block markers. No performance qualification or promotion. External samples include waiting; managed samples are not actual CPU time. Exact process CPU and allocation deltas recorded separately for client/server. Preserve prior C8 failure unchanged." : null, + Workers, + PerWorker, + Count, + Rounds, + Warmup, + SettlingSecondsPerArm = 3, + PoolCapacity = Workers, + Query = "SELECT value FROM network_read WHERE id = 1", + Arms, + Order = "Six Williams rounds, base indices0,1,5,2,4,3 shifted by round modulo6. Every arm occupies every position once. No retries/exclusions.", + Scope = "Separate client/server processes, actual daemon, cleartext loopback, warm point reads. Eight simultaneous held SDK rentals or ADO connections/commands; each sends one command at a time. Shared SDK-owned pool capacity8 per mode. Rental and warmup outside timing. Backend serializes ordinary SQL on its shared client lock, so this includes server queueing. No TLS, remote-host, write, pipelining or prepared-handle qualification.", + Timing = "All8 workers warm then signal readiness; asynchronous start barrier. Wall time is release through completion of all workers. Latency spans each validated read. Store every worker/sample QPC start/end; never sum latencies for throughput. Cancel siblings on failure, drain every worker, preserve partial samples, stop.", + Gate = "Per API all3 arms must pass max/min block throughput spread<=25%. Against EACH baseline stream median throughput>=105%, paired wins>=5/6, median pooled blockp99<=baseline AND median worst-worker blockp99<=baseline. All results/counts correct, zero errors. An API qualifies only if both baseline comparisons pass. No rerun after failed gate.", + Validation = "Each of8 workers completes4096 reads, every scalar42. Every timed block has32768 ExecuteSql requests/responses; no GetInfo/hello or extra HTTP calls. Streaming has32768 decoded SQL frames and responses, zero new HTTP RPCs. All3 pool families retain8 physical owners; SDK and ADO reuse the same families. Verify GetInfo Web8 Native8, CommandSession8 before and after all timed blocks.", + Runtime = RuntimeInformation.FrameworkDescription, + OS = RuntimeInformation.OSDescription, + Environment.MachineName, + Environment.ProcessorCount, + System.Runtime.GCSettings.IsServerGC, + Stopwatch.Frequency, + GitHead = Git("rev-parse", "HEAD"), + GitStatus = status, + Sources = sources, + Binaries = binaries, + Accounting = "CPU/allocations include client and server and bounded counter-snapshot/control-pipe overhead; snapshots outside latency window. " + + (profileOnly ? "Client profiler enabled; diagnostic results cannot qualify a performance change. " : "No profiler. ") + + "No stage probe or ThreadPool/GC/OS tuning. All prior C1 and diagnostic evidence remains separate.", + }); + File.WriteAllText(Path.Combine(output, "source.patch"), Git("diff", "--binary")); + foreach (var file in sources) + { + string destination = Path.Combine(output, "source", file.Name); Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + File.Copy(Path.Combine(root, file.Name), destination); + } + Write(output, "status.json", new { State = "running", StartedUtc = DateTimeOffset.UtcNow }); + if (profileOnly) DiagnosticEvents.Log.Clock(Stopwatch.GetTimestamp(), Stopwatch.Frequency); + await using var host = await BenchmarkDaemonHost.StartAsync(true, null, ct); + Write(output, "host-info.json", host.Info); + var blocks = new List(); var settling = new List(); + try + { + await using (var seed = CSharpDbClient.Create(new() { Transport = CSharpDbTransport.Grpc, GrpcMode = CSharpDbGrpcMode.Native, Endpoint = host.NativeEndpoint.AbsoluteUri })) + { + var result = await seed.ExecuteSqlAsync("CREATE TABLE network_read (id INTEGER PRIMARY KEY, value INTEGER); INSERT INTO network_read VALUES (1, 42)", ct); + if (result.Error is not null) throw new InvalidOperationException(result.Error); + } + foreach (var arm in Arms) + { + await using var group = await Group.OpenAsync(host, arm, ct); + var timer = Stopwatch.StartNew(); var completed = new int[Workers]; + try { await Task.WhenAll(group.Runners.Select(async (runner, worker) => + { + do { await runner.ReadAsync(ct); completed[worker]++; } while (timer.Elapsed < TimeSpan.FromSeconds(3)); + })); } + finally + { + settling.Add(new { arm.Api, arm.Wire, Completed = completed, ElapsedSeconds = timer.Elapsed.TotalSeconds }); + Write(output, "settling.json", settling); + } + } + var settled = await host.CaptureAsync(ct); ValidateFamilies(settled); + Write(output, "pool-proof-before.json", settled); + int[] order = [0, 1, 5, 2, 4, 3]; + for (int round = 0; round < Rounds; round++) foreach (int position in order) + { + Block block = await MeasureAsync(host, Arms[(position + round) % 6], round + 1, output, profileOnly, ct); + blocks.Add(block); Write(output, "blocks.json", blocks); + Console.WriteLine($"{block.Api,-3} {block.Wire,-6} round{block.Round}: {block.OpsPerSecond:N0} ops/sec; p99 {block.P99Us:N1} us; worst workerp99 {block.WorstWorkerP99Us:N1} us"); + } + var final = await host.CaptureAsync(ct); ValidateFamilies(final); Write(output, "pool-proof-after.json", final); + if (!sources.SequenceEqual(SourceHashes(root)) || !binaries.SequenceEqual(BinaryHashes()) || status != Git("status", "--porcelain=v1")) + throw new InvalidOperationException("Source, binary, or working-tree state changed during the run."); + var summaries = blocks.GroupBy(x => new Arm(x.Api, x.Wire)).Select(g => new Summary(g.Key.Api, g.Key.Wire, + Median(g.Select(x => x.OpsPerSecond)), Median(g.Select(x => x.P99Us)), Median(g.Select(x => x.WorstWorkerP99Us)), + g.Max(x => x.OpsPerSecond) / g.Min(x => x.OpsPerSecond) - 1, + g.All(x => x.AccountingValid) ? Median(g.Select(x => x.AllocatedBytes / (double)Count)) : null, + g.All(x => x.AccountingValid))).ToArray(); + var gates = new List(); + foreach (string api in new[] { "SDK", "ADO" }) + { + var stream = summaries.Single(x => x.Api == api && x.Wire == "Stream"); + bool stable = summaries.Where(x => x.Api == api).All(x => x.Spread <= .25); + foreach (string baseline in new[] { "Web", "Native" }) + { + var other = summaries.Single(x => x.Api == api && x.Wire == baseline); + int wins = blocks.Where(x => x.Api == api && x.Wire == "Stream").Count(x => x.OpsPerSecond > blocks.Single(b => b.Api == api && b.Wire == baseline && b.Round == x.Round).OpsPerSecond); + double gain = stream.MedianOpsPerSecond / other.MedianOpsPerSecond - 1; + bool passed = !profileOnly && stable && gain >= .05 && wins >= 5 && stream.MedianP99Us <= other.MedianP99Us && stream.MedianWorstWorkerP99Us <= other.MedianWorstWorkerP99Us; + gates.Add(new(api, baseline, gain, wins, stable, stream.MedianP99Us / other.MedianP99Us, stream.MedianWorstWorkerP99Us / other.MedianWorstWorkerP99Us, passed)); + } + } + Write(output, "summary.json", new { Summaries = summaries, Gates = gates, ApiOutcomes = new[] { "SDK", "ADO" }.Select(api => new { Api = api, Qualified = gates.Where(x => x.Api == api).All(x => x.Passed) }), TotalOperations = blocks.Sum(x => x.Completed), SourcesAndBinariesUnchanged = true, WireCountsValidated = true, ProfileOnly = profileOnly }); + Write(output, "status.json", new { State = "complete", FinishedUtc = DateTimeOffset.UtcNow, Blocks = blocks.Count, TotalOperations = blocks.Sum(x => x.Completed) }); + } + finally + { + await CSharpDbClient.ClearPoolAsync(host.CompatibilityEndpoint.AbsoluteUri); + await CSharpDbClient.ClearPoolAsync(host.NativeEndpoint.AbsoluteUri); + } + } + + private static async Task MeasureAsync(BenchmarkDaemonHost host, Arm arm, int round, string output, bool profileOnly, CancellationToken ct) + { + await using var group = await Group.OpenAsync(host, arm, ct); + await Task.WhenAll(group.Runners.Select(async runner => { for (int i = 0; i < Warmup; i++) await runner.ReadAsync(ct); })); + long[][] starts = Enumerable.Range(0, Workers).Select(_ => new long[PerWorker]).ToArray(); + long[][] ends = Enumerable.Range(0, Workers).Select(_ => new long[PerWorker]).ToArray(); + var completed = new int[Workers]; var readyTicks = new long[Workers]; var doneTicks = new long[Workers]; var failures = new string?[Workers]; + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); int readyCount = 0; + using var cancel = CancellationTokenSource.CreateLinkedTokenSource(ct); + async Task Worker(int worker) + { + try + { + readyTicks[worker] = Stopwatch.GetTimestamp(); if (Interlocked.Increment(ref readyCount) == Workers) ready.SetResult(); + await release.Task.WaitAsync(cancel.Token); + for (int n = 0; n < PerWorker; n++) + { + starts[worker][n] = Stopwatch.GetTimestamp(); await group.Runners[worker].ReadAsync(cancel.Token); + ends[worker][n] = Stopwatch.GetTimestamp(); completed[worker]++; + } + } + catch (Exception error) { failures[worker] = error.ToString(); cancel.Cancel(); throw; } + finally { doneTicks[worker] = Stopwatch.GetTimestamp(); } + } + Task[] workers = Enumerable.Range(0, Workers).Select(Worker).ToArray(); + await ready.Task.WaitAsync(ct); + using var process = Process.GetCurrentProcess(); + BenchmarkDaemonHost.Snapshot before; + try { before = await host.CaptureAsync(ct); } catch { cancel.Cancel(); try { await Task.WhenAll(workers); } catch { } throw; } + long clientAllocatedBefore = GC.GetTotalAllocatedBytes(true); + double clientCpuBefore = process.TotalProcessorTime.TotalMilliseconds; + long allocated = clientAllocatedBefore + before.ServerAllocatedBytes; + double cpu = clientCpuBefore + before.ServerCpuMs; + if (profileOnly) DiagnosticEvents.Log.Block(arm.Api, arm.Wire, round, 1, Stopwatch.GetTimestamp()); + long blockStart = Stopwatch.GetTimestamp(), blockEnd = blockStart; + BenchmarkDaemonHost.Snapshot? after = null; Exception? workerFailure = null; Exception? captureFailure = null; + bool accountingValid = false; + try { release.SetResult(); await Task.WhenAll(workers); } + catch (Exception error) { workerFailure = error; throw; } + finally + { + blockEnd = Stopwatch.GetTimestamp(); + if (profileOnly) DiagnosticEvents.Log.Block(arm.Api, arm.Wire, round, 0, Stopwatch.GetTimestamp()); + try { after = await host.CaptureAsync(ct); } catch (Exception error) { captureFailure = error; } + long clientAllocatedAfter = GC.GetTotalAllocatedBytes(true); + double clientCpuAfter = process.TotalProcessorTime.TotalMilliseconds; + long clientAllocated = clientAllocatedAfter - clientAllocatedBefore; + long serverAllocated = (after?.ServerAllocatedBytes ?? before.ServerAllocatedBytes) - before.ServerAllocatedBytes; + double clientCpu = clientCpuAfter - clientCpuBefore; + double serverCpu = (after?.ServerCpuMs ?? before.ServerCpuMs) - before.ServerCpuMs; + accountingValid = after is not null && clientAllocated >= 0 && serverAllocated >= 0 + && double.IsFinite(clientCpu) && clientCpu >= 0 && double.IsFinite(serverCpu) && serverCpu >= 0; + allocated = clientAllocated + serverAllocated; + cpu = clientCpu + serverCpu; + string stem = $"{arm.Api}-{arm.Wire}-{round}"; + File.WriteAllLines(Path.Combine(output, stem + ".csv"), new[] { "Worker,Sample,StartTicks,EndTicks,ElapsedTicks" }.Concat( + Enumerable.Range(0, Workers).SelectMany(w => Enumerable.Range(0, completed[w]).Select(n => $"{w + 1},{n + 1},{starts[w][n]},{ends[w][n]},{ends[w][n] - starts[w][n]}")))); + Write(output, stem + ".metrics.json", new { Completed = completed.Sum(), Requested = Count, PerWorker = completed, ReadyTicks = readyTicks, DoneTicks = doneTicks, BlockStart = blockStart, BlockEnd = blockEnd, ElapsedTicks = blockEnd - blockStart, AllocatedBytes = allocated, CpuMs = cpu, ClientAllocatedBytes = clientAllocated, ServerAllocatedBytes = serverAllocated, ClientCpuMs = clientCpu, ServerCpuMs = serverCpu, ClientAllocatedBefore = clientAllocatedBefore, ClientAllocatedAfter = clientAllocatedAfter, ServerAllocatedBefore = before.ServerAllocatedBytes, ServerAllocatedAfter = after?.ServerAllocatedBytes, ClientCpuBeforeMs = clientCpuBefore, ClientCpuAfterMs = clientCpuAfter, ServerCpuBeforeMs = before.ServerCpuMs, ServerCpuAfterMs = after?.ServerCpuMs, AccountingValid = accountingValid, WorkerFailures = failures, CaptureFailure = captureFailure?.ToString() }); + Write(output, stem + ".wire.json", new { Before = before.Wire, After = after?.Wire, HttpBefore = before.Http, HttpAfter = after?.Http }); + if (captureFailure is not null && workerFailure is null) throw new IOException("Counter snapshot failed; partial raw samples retained.", captureFailure); + if (!profileOnly && !accountingValid && workerFailure is null) + throw new InvalidDataException("Resource counters decreased or were invalid; raw counters retained. Qualification stopped without retry."); + } + if (completed.Any(n => n != PerWorker) || readyTicks.Any(t => t > blockStart)) throw new InvalidOperationException("Worker readiness or operation count mismatch."); + ValidateWire(before.Http, after!.Http, after.Wire - before.Wire, arm, Count); + var workerQuantiles = Enumerable.Range(0, Workers).Select(w => { long[] ticks = Enumerable.Range(0, PerWorker).Select(n => ends[w][n] - starts[w][n]).Order().ToArray(); return Percentile(ticks, .99); }).ToArray(); + long[] all = Enumerable.Range(0, Workers).SelectMany(w => Enumerable.Range(0, PerWorker).Select(n => ends[w][n] - starts[w][n])).Order().ToArray(); + return new(arm.Api, arm.Wire, round, Count, Count * (double)Stopwatch.Frequency / (blockEnd - blockStart), Percentile(all, .50), Percentile(all, .95), Percentile(all, .99), workerQuantiles.Max(), allocated, cpu, accountingValid); + } + + private static void ValidateFamilies(BenchmarkDaemonHost.Snapshot snapshot) + { + long web = snapshot.Http.Where(x => x.Method.EndsWith("/GetInfo", StringComparison.Ordinal) && x.Protocol == "HTTP/1.1").Sum(x => x.Value); + long native = snapshot.Http.Where(x => x.Method.EndsWith("/GetInfo", StringComparison.Ordinal) && x.Protocol == "HTTP/2").Sum(x => x.Value); + long streams = snapshot.Http.Where(x => x.Method.EndsWith("/CommandSession", StringComparison.Ordinal)).Sum(x => x.Value); + if (web != Workers || native != Workers || streams != Workers || snapshot.Wire.UnaryOther != 2 * Workers || snapshot.Wire.StreamOther != 2 * Workers) + throw new InvalidOperationException($"Expected exactly8 retained physical owners per pool family shared by SDK/ADO: Web{web}, Native{native}, Stream{streams}."); + } + private sealed class Group : IAsyncDisposable + { + public Runner[] Runners { get; private init; } = []; + public static async Task OpenAsync(BenchmarkDaemonHost host, Arm arm, CancellationToken ct) + { + Task[] opens = Enumerable.Range(0, Workers).Select(_ => Runner.OpenAsync(host, arm, ct, Workers)).ToArray(); + try { return new() { Runners = await Task.WhenAll(opens) }; } + catch + { + foreach (var open in opens) + if (open.IsCompletedSuccessfully) + try { await open.Result.DisposeAsync(); } catch { /* Preserve the opening error; retire all successful rentals. */ } + throw; + } + } + public async ValueTask DisposeAsync() + { + List? errors = null; + foreach (var runner in Runners) + { + try { await runner.DisposeAsync(); } + catch (Exception error) { (errors ??= []).Add(error); } + } + if (errors is not null) throw new AggregateException("Failed to return benchmark rentals.", errors); + } + } + private sealed record Summary(string Api, string Wire, double MedianOpsPerSecond, double MedianP99Us, double MedianWorstWorkerP99Us, double Spread, double? BytesPerOp, bool AccountingValid); + private sealed record Gate(string Api, string Baseline, double ThroughputChange, int PairedWins, bool AllApiArmsStable, double P99Ratio, double WorstWorkerP99Ratio, bool Passed); + private sealed record Block(string Api, string Wire, int Round, int Completed, double OpsPerSecond, double P50Us, double P95Us, double P99Us, double WorstWorkerP99Us, long AllocatedBytes, double CpuMs, bool AccountingValid); +} diff --git a/tests/CSharpDB.NetworkBenchmarks/GrpcPooledAllocationProfile.cs b/tests/CSharpDB.NetworkBenchmarks/GrpcPooledAllocationProfile.cs new file mode 100644 index 00000000..ffa6202e --- /dev/null +++ b/tests/CSharpDB.NetworkBenchmarks/GrpcPooledAllocationProfile.cs @@ -0,0 +1,362 @@ +using System.Diagnostics; +using System.Runtime.ExceptionServices; +using System.Runtime.InteropServices; +using System.Text.Json; +using CSharpDB.Client; +using CSharpDB.Data; +using static CSharpDB.NetworkBenchmarks.GrpcSessionBenchmark; + +namespace CSharpDB.NetworkBenchmarks; + +/// Profiles warmed pool checkout/return without executing SQL. +internal static class GrpcPooledAllocationProfile +{ + private const int Workers = 8, PerWorker = 32768, Count = Workers * PerWorker, Warmup = 256; + private static readonly string[] BlockOrder = ["SDK", "ADO", "ADO", "SDK"]; + + internal static async Task RunAsync(string[] args) + { + string? output = null; + bool ownsOutput = false; + try + { + int oi = Array.IndexOf(args, "--output"), ei = Array.IndexOf(args, "--experiment"); + if (args.Length != 4 || oi is not (0 or 2) || ei is not (0 or 2) || oi == ei + || args[ei + 1] != "pooled-allocation-profile") + throw new ArgumentException("Use --experiment pooled-allocation-profile --output ; all settings are fixed."); + string root = Path.GetFullPath(Git("rev-parse", "--show-toplevel")); + output = Path.GetFullPath(args[oi + 1]); + if (output.Equals(root, StringComparison.OrdinalIgnoreCase) + || output.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("Keep evidence outside the repository."); + if (Directory.Exists(output) || File.Exists(output)) throw new ArgumentException("Evidence path must be new."); + CheckEnvironment(); + Directory.CreateDirectory(output); + ownsOutput = true; + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await RunCoreAsync(root, output, deadline.Token); + return 0; + } + catch (Exception error) + { + if (ownsOutput && output is not null) + { + File.AppendAllText(Path.Combine(output, "failures.jsonl"), + JsonSerializer.Serialize(new { Utc = DateTimeOffset.UtcNow, Error = error.ToString() }) + Environment.NewLine); + Write(output, "status.json", new { State = "failed", DiagnosticOnly = true, QualificationEligible = false, + AllocationAccounting = "ClientProcessManagedPrecise", FinishedUtc = DateTimeOffset.UtcNow }); + } + Console.Error.WriteLine(error); + return 1; + } + } + + private static async Task RunCoreAsync(string root, string output, CancellationToken ct) + { + Hash[] sources = SourceHashes(root), binaries = BinaryHashes(); + string head = Git("rev-parse", "HEAD"), status = Git("status", "--porcelain=v1"); + Write(output, "protocol.json", new + { + Version = 1, Experiment = "pooled-allocation-profile", DiagnosticOnly = true, QualificationEligible = false, + AllocationAccounting = "ClientProcessManagedPrecise", Workers, PerWorker, Count, BlockOrder, TotalCycles = Count * BlockOrder.Length, + Warmup, PoolCapacity = Workers, DeadlineSeconds = 120, WorkerDrainSeconds = 15, CleanupSeconds = 20, + SqlCommands = 0, ClientProcessId = Environment.ProcessId, WorkingDirectory = Environment.CurrentDirectory, + Scope = "Actual isolated daemon; native HTTP/2 streaming; shared pool8. Prewarm eight simultaneously held SDK rentals then eight ADO opens. Reuse options and the same connection-string object. Warm256 zero-SQL cycles per worker/API once. Four measured blocks SDK,ADO,ADO,SDK.", + Counter = "GC.GetTotalAllocatedBytes(precise:true) in the client process, before QPC start/release and after all workers/QPC end, before output or control IPC. Exact process-managed allocation includes remaining harness, scheduling, pool-timer and background activity; bytes/cycle are not exact product-only allocation or exact per-type counts. Server allocation is not collected.", + WorkersMeaning = "All8 workers await an unreleased RunContinuationsAsynchronously barrier before any cycle. SDK cycle awaits RentAsync then DisposeAsync; ADO cycle constructs, opens and asynchronously disposes a connection. No SQL, commands, per-cycle timing, delegates, CSV, markers or forced asynchronous hops.", + Trace = "Expected client DOTNET_DiagnosticPorts launch tracing is allowed. The owned-daemon helper removes diagnostic ports. AllocationTick stacks are sampled attribution, not exact allocation totals by type. No throughput/product qualification.", + ClockAnchors = "Two CSharpDB-Baseline-Timeline event2 Clock anchors, IDs1/2, before first and after fourth allocation windows; payload QPC/frequency retained in clock-anchors.json. No per-cycle events.", + Conditions = "Client and exact owned daemon must remain Normal; read-only before each block and after all blocks. Reject inherited startup hooks, benchmark parent and enabled CLR profiling. No GC/ThreadPool/OS changes.", + Validation = "After prewarm exactly8 native CommandSession HTTP calls and wire(0,0,0,16,16): eight hello and eight GetInfo responses. All warmup and measured blocks must leave every HTTP/wire count unchanged. No retries/exclusions.", + Runtime = RuntimeInformation.FrameworkDescription, OS = RuntimeInformation.OSDescription, Environment.ProcessorCount, + System.Runtime.GCSettings.IsServerGC, Stopwatch.Frequency, + DiagnosticPorts = Environment.GetEnvironmentVariable("DOTNET_DiagnosticPorts"), + DefaultDiagnosticPortSuspend = Environment.GetEnvironmentVariable("DOTNET_DefaultDiagnosticPortSuspend"), + GitHead = head, GitStatus = status, Sources = sources, Binaries = binaries, + }); + File.WriteAllText(Path.Combine(output, "source.patch"), Git("diff", "--binary")); + foreach (Hash source in sources) + { + string destination = Path.Combine(output, "source", source.Name); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + File.Copy(Path.Combine(root, source.Name), destination, false); + } + var blocks = new List(); + var conditions = new List(); + var anchors = new List(); + BenchmarkDaemonHost? host = null; + DateTime? creation = null; + Exception? failure = null; + try + { + RecordConditions(output, conditions, "ClientBeforeDaemon", null, null); + host = await BenchmarkDaemonHost.StartAsync(true, null, ct); + using (var process = Process.GetProcessById(host.Info.ServerProcessId)) creation = process.StartTime.ToUniversalTime(); + Write(output, "host-info.json", host.Info); + RecordConditions(output, conditions, "AfterDaemonStart", host, creation); + var options = new CSharpDbClientOptions { Transport = CSharpDbTransport.Grpc, GrpcMode = CSharpDbGrpcMode.Native, + GrpcCallMode = CSharpDbGrpcCallMode.Streaming, Endpoint = host.NativeEndpoint.AbsoluteUri }; + string connectionString = $"Transport=Grpc;Grpc Mode=Native;Grpc Call Mode=Streaming;Endpoint={host.NativeEndpoint};Pooling=true;Max Pool Size={Workers}"; + var initial = await host.CaptureTimingAsync(ct); + if (initial.Http.Length != 0 || initial.Wire != new Counts(0, 0, 0, 0, 0)) + throw new InvalidDataException("Unexpected daemon traffic before pool prewarm."); + await PrewarmAsync("SDK", options, connectionString, ct); + var sdk = await host.CaptureTimingAsync(ct); + ValidateOwners(sdk); + await PrewarmAsync("ADO", options, connectionString, ct); + var ado = await host.CaptureTimingAsync(ct); + ValidateOwners(ado); + ValidateUnchanged(sdk, ado); + Write(output, "prewarm.json", new { Before = initial, Sdk = sdk, Ado = ado, SharedOwners = Workers, SqlCommands = 0 }); + var warmups = new List(); + foreach (string api in new[] { "SDK", "ADO" }) + { + ProfileBlock warm = await RunCyclesAsync(api, 0, Warmup, false, options, connectionString, ct); + warmups.Add(warm); + Write(output, "warmup.json", warmups); + ThrowIfFailed(warm); + ValidateUnchanged(ado, await host.CaptureTimingAsync(ct)); + } + var before = await host.CaptureTimingAsync(ct); + ValidateOwners(before); + Write(output, "pool-proof-before.json", before); + RecordClock(output, anchors, 1); + for (int index = 0; index < BlockOrder.Length; index++) + { + string api = BlockOrder[index]; + RecordConditions(output, conditions, $"BeforeBlock{index + 1}-{api}", host, creation); + var wireBefore = await host.CaptureTimingAsync(ct); + ValidateUnchanged(before, wireBefore); + ProfileBlock block = await RunCyclesAsync(api, index + 1, PerWorker, true, options, connectionString, ct); + blocks.Add(block); + string stem = $"{index + 1:D2}-{api}"; + Write(output, stem + ".metrics.json", block); + Write(output, "blocks.json", blocks); + ThrowIfFailed(block); + var wireAfter = await host.CaptureTimingAsync(ct); + Write(output, stem + ".wire.json", new { Before = wireBefore, After = wireAfter }); + ValidateUnchanged(wireBefore, wireAfter); + Console.WriteLine($"Block{index + 1} {api}: {block.Completed} cycles; client managed bytes/cycle {block.ClientBytesPerCycle:N1} (diagnostic process total)"); + } + RecordClock(output, anchors, 2); + var after = await host.CaptureTimingAsync(ct); + Write(output, "pool-proof-after.json", after); + ValidateUnchanged(before, after); + RecordConditions(output, conditions, "AfterAllBlocks", host, creation); + if (blocks.Count != 4 || blocks.Sum(x => x.Completed) != 1048576 || blocks.Any(x => !x.AllocationAccountingValid)) + throw new InvalidDataException("Every fixed allocation block is required."); + } + catch (Exception error) { failure = error; } + finally + { + var errors = new List(); + if (host is not null) + { + try { await CSharpDbClient.ClearPoolAsync(host.NativeEndpoint.AbsoluteUri).AsTask().WaitAsync(TimeSpan.FromSeconds(20)); } + catch (Exception error) { errors.Add(error.ToString()); } + try { await host.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(20)); } + catch (Exception error) { errors.Add(error.ToString()); } + if (errors.Count != 0 && creation is { } expected) + { + try + { + using var process = Process.GetProcessById(host.Info.ServerProcessId); + if (process.StartTime.ToUniversalTime() == expected && !process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + } + } + catch (ArgumentException) { } + catch (Exception error) { errors.Add(error.ToString()); } + } + } + bool same = sources.SequenceEqual(SourceHashes(root)) && binaries.SequenceEqual(BinaryHashes()) + && head == Git("rev-parse", "HEAD") && status == Git("status", "--porcelain=v1"); + Write(output, "cleanup.json", new { HostStarted = host is not null, Host = host?.Info, CleanupErrors = errors, + OwnedDaemonExited = host is not null && errors.Count == 0, SourceAndRuntimeUnchanged = same, + CompletedBlocks = blocks.Count, CompletedCycles = blocks.Sum(x => x.Completed), PrimaryFailure = failure?.ToString() }); + if (!same) failure ??= new InvalidDataException("Source, runtime, HEAD or working-tree state changed."); + if (errors.Count != 0) failure ??= new IOException("Owned pool/daemon cleanup failed; evidence retained."); + } + if (failure is not null) ExceptionDispatchInfo.Capture(failure).Throw(); + Write(output, "status.json", new { State = "complete", DiagnosticOnly = true, QualificationEligible = false, + AllocationAccounting = "ClientProcessManagedPrecise", FinishedUtc = DateTimeOffset.UtcNow, + Blocks = blocks.Count, TotalCycles = blocks.Sum(x => x.Completed), SqlCommands = 0, + SourceAndRuntimeUnchanged = true, OwnedDaemonExited = true }); + } + + private static async Task RunCyclesAsync(string api, int block, int perWorker, bool measured, + CSharpDbClientOptions options, string connectionString, CancellationToken ct) + { + var completed = new int[Workers]; + var readyTicks = new long[Workers]; + var starts = new long[Workers]; + var done = new long[Workers]; + var errors = new string?[Workers]; + using var cancel = CancellationTokenSource.CreateLinkedTokenSource(ct); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int readyCount = 0; + async Task Worker(int worker) + { + try + { + readyTicks[worker] = Stopwatch.GetTimestamp(); + if (Interlocked.Increment(ref readyCount) == Workers) ready.TrySetResult(); + await release.Task.WaitAsync(cancel.Token); + starts[worker] = Stopwatch.GetTimestamp(); + if (api == "SDK") + { + for (int n = 0; n < perWorker; n++) + { + { await using var rental = await CSharpDbClient.RentAsync(options, Workers, cancel.Token); } + completed[worker]++; + } + } + else + { + for (int n = 0; n < perWorker; n++) + { + { + await using var connection = new CSharpDbConnection(connectionString); + await connection.OpenAsync(cancel.Token); + } + completed[worker]++; + } + } + } + catch (Exception error) { errors[worker] = error.ToString(); cancel.Cancel(); throw; } + finally { done[worker] = Stopwatch.GetTimestamp(); } + } + Task all = Task.WhenAll(Enumerable.Range(0, Workers).Select(Worker)); + Task bounded = all.WaitAsync(ct); // Establish the block-level cancellation wrapper before the allocation counter. + long start = 0, end = 0; + long? allocatedBefore = null, allocatedAfter = null; + Exception? executionFailure = null, drainFailure = null; + try + { + await ready.Task.WaitAsync(ct); + if (measured) allocatedBefore = GC.GetTotalAllocatedBytes(precise: true); + start = Stopwatch.GetTimestamp(); + release.TrySetResult(); + await bounded; + end = Stopwatch.GetTimestamp(); + if (measured) allocatedAfter = GC.GetTotalAllocatedBytes(precise: true); + } + catch (Exception error) + { + executionFailure = error; + cancel.Cancel(); + try { await all.WaitAsync(TimeSpan.FromSeconds(15)); } + catch (Exception drain) { if (!all.IsCompleted) drainFailure = drain; } + end = Stopwatch.GetTimestamp(); + if (measured && allocatedBefore.HasValue) allocatedAfter = GC.GetTotalAllocatedBytes(precise: true); + } + bool allocationValid = measured && allocatedBefore.HasValue && allocatedAfter >= allocatedBefore; + long? allocated = allocationValid ? allocatedAfter - allocatedBefore : null; + return new(block, api, perWorker * Workers, completed.Sum(), completed, readyTicks, starts, done, errors, + start, end, end - start, allocatedBefore, allocatedAfter, allocated, + allocationValid && completed.Sum() != 0 ? allocated / (double)completed.Sum() : null, + allocationValid, all.IsCompleted, executionFailure?.ToString(), drainFailure?.ToString()); + } + + private static void ThrowIfFailed(ProfileBlock block) + { + if (block.ExecutionFailure is not null || block.DrainFailure is not null || !block.WorkersDrained + || block.Completed != block.Requested || block.PerWorker.Any(n => n != block.Requested / Workers) + || block.WorkerFailures.Any(x => x is not null)) throw new InvalidOperationException("Pool cycle phase failed; partial counts retained without retry."); + if (block.BlockStart <= 0 || block.BlockEnd <= block.BlockStart || block.ReadyTicks.Any(t => t <= 0 || t > block.BlockStart) + || block.WorkerStartTicks.Any(t => t < block.BlockStart || t > block.BlockEnd) + || block.DoneTicks.Where((t, worker) => t < block.WorkerStartTicks[worker] || t > block.BlockEnd).Any()) + throw new InvalidDataException("Pool cycle worker/QPC bounds differ."); + if (block.Block != 0 && !block.AllocationAccountingValid) throw new InvalidDataException("Managed allocation counters decreased or are missing."); + } + + private static async Task PrewarmAsync(string api, CSharpDbClientOptions options, string connectionString, CancellationToken ct) + { + var rentals = new IAsyncDisposable?[Workers]; + using var cancel = CancellationTokenSource.CreateLinkedTokenSource(ct); + async Task Open(int worker) + { + try + { + if (api == "SDK") rentals[worker] = await CSharpDbClient.RentAsync(options, Workers, cancel.Token); + else + { + var connection = new CSharpDbConnection(connectionString); + rentals[worker] = connection; + await connection.OpenAsync(cancel.Token); + } + } + catch { cancel.Cancel(); throw; } + } + Task all = Task.WhenAll(Enumerable.Range(0, Workers).Select(Open)); + try { await all.WaitAsync(ct); } + catch + { + cancel.Cancel(); + try { await all.WaitAsync(TimeSpan.FromSeconds(15)); } catch { } + throw; + } + finally + { + await Task.WhenAll(rentals.Where(x => x is not null).Select(x => x!.DisposeAsync().AsTask())) + .WaitAsync(TimeSpan.FromSeconds(20)); + } + } + + private static void ValidateOwners(BenchmarkDaemonHost.TimingSnapshot snapshot) + { + if (!double.IsFinite(snapshot.ServerCpuMs) || snapshot.ServerCpuMs < 0 + || snapshot.Wire != new Counts(0, 0, 0, 2 * Workers, 2 * Workers) + || snapshot.Http.Length != 1 || snapshot.Http[0].Value != Workers + || !snapshot.Http[0].Method.EndsWith("/CommandSession", StringComparison.Ordinal) + || snapshot.Http[0].Protocol != "HTTP/2" || !snapshot.Http[0].ContentType.StartsWith("application/grpc", StringComparison.Ordinal) + || snapshot.Http[0].ContentType.Contains("grpc-web", StringComparison.Ordinal)) + throw new InvalidDataException("Zero-SQL profile requires exactly eight retained stream owners and16 hello/GetInfo replies."); + } + private static void ValidateUnchanged(BenchmarkDaemonHost.TimingSnapshot before, BenchmarkDaemonHost.TimingSnapshot after) + { + ValidateOwners(before); ValidateOwners(after); + if (!before.Http.SequenceEqual(after.Http) || before.Wire != after.Wire || after.ServerCpuMs < before.ServerCpuMs) + throw new InvalidDataException("Checkout/return caused traffic, owner replacement or invalid daemon CPU accounting."); + } + private static void RecordClock(string output, List anchors, int id) + { + long qpc = Stopwatch.GetTimestamp(); + TimelineEventSource.Log.Clock(id, qpc, Stopwatch.Frequency); + anchors.Add(new { Id = id, Qpc = qpc, Frequency = Stopwatch.Frequency, ClientProcessId = Environment.ProcessId }); + Write(output, "clock-anchors.json", anchors); + } + private static void CheckEnvironment() + { + foreach (string name in new[] { "DOTNET_STARTUP_HOOKS", "CSHARPDB_BENCHMARK_PARENT" }) + if (Environment.GetEnvironmentVariable(name) is not null) throw new InvalidOperationException($"Unexpected inherited environment: {name}."); + foreach (string name in new[] { "COR_ENABLE_PROFILING", "CORECLR_ENABLE_PROFILING" }) + if (Environment.GetEnvironmentVariable(name) is { } value && value != "0") throw new InvalidOperationException($"Enabled CLR profiling: {name}."); + if (Debugger.IsAttached) throw new InvalidOperationException("Do not attach a debugger to allocation profiling."); + } + private static void RecordConditions(string output, List checks, string stage, BenchmarkDaemonHost? host, DateTime? creation) + { + CheckEnvironment(); + using var client = Process.GetCurrentProcess(); + using Process? server = host is null ? null : Process.GetProcessById(host.Info.ServerProcessId); + client.Refresh(); server?.Refresh(); + checks.Add(new { Stage = stage, Utc = DateTimeOffset.UtcNow, Qpc = Stopwatch.GetTimestamp(), + Client = new { Pid = client.Id, CreationUtc = client.StartTime.ToUniversalTime(), Priority = client.PriorityClass.ToString() }, + Server = server is null ? null : new { Pid = server.Id, CreationUtc = server.StartTime.ToUniversalTime(), Priority = server.PriorityClass.ToString() }, + HookEnvironmentAbsent = true, ClrProfilingEnvironmentDisabled = true, DiagnosticPortAllowed = true }); + Write(output, "priority-checks.json", checks); + if (client.PriorityClass != ProcessPriorityClass.Normal || server is not null + && (server.PriorityClass != ProcessPriorityClass.Normal || server.StartTime.ToUniversalTime() != creation)) + throw new InvalidOperationException("Client and exact owned daemon must have Normal priority."); + } + + private sealed record ProfileBlock(int Block, string Api, int Requested, int Completed, int[] PerWorker, + long[] ReadyTicks, long[] WorkerStartTicks, long[] DoneTicks, string?[] WorkerFailures, + long BlockStart, long BlockEnd, long ElapsedTicks, long? ClientAllocatedBefore, long? ClientAllocatedAfter, + long? ClientAllocatedBytes, double? ClientBytesPerCycle, bool AllocationAccountingValid, bool WorkersDrained, + string? ExecutionFailure, string? DrainFailure) + { public string AllocationAccounting => "ClientProcessManagedPrecise"; } +} diff --git a/tests/CSharpDB.NetworkBenchmarks/GrpcPooledLifecycleBenchmark.cs b/tests/CSharpDB.NetworkBenchmarks/GrpcPooledLifecycleBenchmark.cs new file mode 100644 index 00000000..b09ecb2b --- /dev/null +++ b/tests/CSharpDB.NetworkBenchmarks/GrpcPooledLifecycleBenchmark.cs @@ -0,0 +1,526 @@ +using System.Diagnostics; +using System.Globalization; +using System.Runtime.ExceptionServices; +using System.Runtime.InteropServices; +using System.Text.Json; +using CSharpDB.Client; +using CSharpDB.Client.Models; +using CSharpDB.Data; +using static CSharpDB.NetworkBenchmarks.GrpcSessionBenchmark; + +namespace CSharpDB.NetworkBenchmarks; + +/// Describes the cost of a complete pooled request relative to a held rental. +internal static class GrpcPooledLifecycleBenchmark +{ + private const int Workers = 8, PerWorker = 4096, Count = Workers * PerWorker, Warmup = 256; + private const string Query = "SELECT value FROM network_read WHERE id = 1"; + private static readonly ArmDefinition[] Arms = + [new("SDK-Held", "SDK", false), new("SDK-Lifecycle", "SDK", true), + new("ADO-Held", "ADO", false), new("ADO-Lifecycle", "ADO", true)]; + private static readonly int[][] Schedule = + [[0, 1, 3, 2], [1, 2, 0, 3], [2, 3, 1, 0], [3, 0, 2, 1], + [3, 0, 2, 1], [2, 3, 1, 0], [1, 2, 0, 3], [0, 1, 3, 2]]; + + internal static async Task RunAsync(string[] args) + { + string? output = null; + bool ownsOutput = false; + try + { + int outputIndex = Array.IndexOf(args, "--output"), experimentIndex = Array.IndexOf(args, "--experiment"); + if (args.Length != 4 || outputIndex is not (0 or 2) || experimentIndex is not (0 or 2) + || outputIndex == experimentIndex || args[experimentIndex + 1] != "pooled-lifecycle-c8") + throw new ArgumentException("Use --experiment pooled-lifecycle-c8 --output ; workload settings are fixed."); + string root = Path.GetFullPath(Git("rev-parse", "--show-toplevel")); + output = Path.GetFullPath(args[outputIndex + 1]); + if (output.Equals(root, StringComparison.OrdinalIgnoreCase) + || output.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("Keep evidence outside the repository."); + if (Directory.Exists(output) || File.Exists(output)) throw new ArgumentException("Evidence path must be new."); + CheckEnvironment(); + Directory.CreateDirectory(output); + ownsOutput = true; + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(300)); + await RunCoreAsync(root, output, deadline.Token); + return 0; + } + catch (Exception error) + { + if (ownsOutput && output is not null) + { + File.AppendAllText(Path.Combine(output, "failures.jsonl"), + JsonSerializer.Serialize(new { Utc = DateTimeOffset.UtcNow, Error = error.ToString() }) + Environment.NewLine); + Write(output, "status.json", new { State = "failed", DiagnosticOnly = true, QualificationEligible = false, + AllocationAccounting = "NotCollected", FinishedUtc = DateTimeOffset.UtcNow }); + } + Console.Error.WriteLine(error); + return 1; + } + } + + private static async Task RunCoreAsync(string root, string output, CancellationToken ct) + { + Hash[] sources = SourceHashes(root), binaries = BinaryHashes(); + string head = Git("rev-parse", "HEAD"), status = Git("status", "--porcelain=v1"); + Write(output, "protocol.json", new + { + Version = 1, Experiment = "pooled-lifecycle-c8", DiagnosticOnly = true, QualificationEligible = false, + AllocationAccounting = "NotCollected", Workers, PerWorker, Count, Rounds = Schedule.Length, Warmup, + SettlingSecondsPerArm = 3, PoolCapacity = Workers, TotalOperations = Schedule.Length * Arms.Length * Count, + Query, Arms, Schedule, WorkingDirectory = Environment.CurrentDirectory, + Scope = "Separate actual daemon, native HTTP/2 streaming, one outstanding read per worker. SDK and ADO share eight physical owners. Cleartext loopback only.", + Timing = "Lifecycle samples include rent/open, one validated read, and awaited dispose/close. Held rental and command creation are outside timing. Throughput is barrier release through completion of all eight workers; nearest-rank pooled and worst-worker p99.", + Prewarm = "Concurrently rent and hold eight SDK owners, read once each, then release. Concurrently open and hold eight ADO connections from the same pool, read once each, then release. Sixteen prewarm SQL reads are outside measurement.", + WarmupMeaning = "256 complete operations per worker before each block; settle each of four arms for three seconds once before all measured blocks.", + Order = "Four Williams rows from base [0,1,3,2] shifted modulo4; repeat those rows in reverse row order. Every arm occupies each position twice. No retries or exclusions.", + Conditions = "Require client and exact owned daemon Normal priority before every measured block and after all blocks; read-only checks, no priority changes. Reject inherited hooks, benchmark-parent, diagnostic ports and enabled CLR profiling. No allocation counters, profiler, forced GC or runtime/OS tuning.", + Gates = "Descriptive only: each arm has fixed max/min block throughput spread <=25%. Report lifecycle/held ratios within each API. No product improvement or cross-historical qualification.", + Validation = "Every timed block has32768 SQL frames and responses, zero other frames and zero new HTTP calls. Cumulative HTTP is one unary seed plus eight CommandSession calls; stream hello/GetInfo frames16. Stop on any SQL/value/count/CPU/source/runtime/condition failure.", + DeadlineSeconds = 300, WorkerDrainSeconds = 15, CleanupSeconds = 20, + Runtime = RuntimeInformation.FrameworkDescription, OS = RuntimeInformation.OSDescription, + Environment.MachineName, Environment.ProcessorCount, System.Runtime.GCSettings.IsServerGC, + Stopwatch.Frequency, GitHead = head, GitStatus = status, Sources = sources, Binaries = binaries, + }); + File.WriteAllText(Path.Combine(output, "source.patch"), Git("diff", "--binary")); + foreach (Hash source in sources) + { + string destination = Path.Combine(output, "source", source.Name); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + File.Copy(Path.Combine(root, source.Name), destination, false); + } + var checks = new List(); + var blocks = new List(); + BenchmarkDaemonHost? host = null; + DateTime? daemonCreationUtc = null; + Exception? failure = null; + try + { + RecordConditions(output, checks, "ClientBeforeDaemon", null); + host = await BenchmarkDaemonHost.StartAsync(true, null, ct); + using (var daemon = Process.GetProcessById(host.Info.ServerProcessId)) daemonCreationUtc = daemon.StartTime.ToUniversalTime(); + Write(output, "host-info.json", host.Info); + RecordConditions(output, checks, "AfterDaemonStart", host, daemonCreationUtc); + var options = new CSharpDbClientOptions { Transport = CSharpDbTransport.Grpc, GrpcMode = CSharpDbGrpcMode.Native, + GrpcCallMode = CSharpDbGrpcCallMode.Streaming, Endpoint = host.NativeEndpoint.AbsoluteUri }; + string connectionString = $"Transport=Grpc;Grpc Mode=Native;Grpc Call Mode=Streaming;Endpoint={host.NativeEndpoint};Pooling=true;Max Pool Size={Workers}"; + await using (var seed = CSharpDbClient.Create(new() { Transport = CSharpDbTransport.Grpc, + GrpcMode = CSharpDbGrpcMode.Native, Endpoint = host.NativeEndpoint.AbsoluteUri })) + { + SqlExecutionResult result = await seed.ExecuteSqlAsync( + "CREATE TABLE network_read (id INTEGER PRIMARY KEY, value INTEGER); INSERT INTO network_read VALUES (1, 42)", ct); + if (result.Error is not null) throw new InvalidDataException(result.Error); + } + var seedSnapshot = await host.CaptureTimingAsync(ct); + await using (var group = await OpenGroupAsync(Arms[0], options, connectionString, ct)) + await RunParallelAsync(async (worker, token) => await group[worker].ReadAsync(token), ct); + var sdkPrewarm = await host.CaptureTimingAsync(ct); + ValidateOwners(sdkPrewarm); + await using (var group = await OpenGroupAsync(Arms[2], options, connectionString, ct)) + await RunParallelAsync(async (worker, token) => await group[worker].ReadAsync(token), ct); + var adoPrewarm = await host.CaptureTimingAsync(ct); + ValidateOwners(adoPrewarm); + ValidateWire(sdkPrewarm.Http, adoPrewarm.Http, adoPrewarm.Wire - sdkPrewarm.Wire, new("ADO", "Stream"), Workers); + Write(output, "prewarm.json", new { Seed = seedSnapshot, Sdk = sdkPrewarm, Ado = adoPrewarm, SqlReads = 2 * Workers, SharedOwners = Workers }); + if (seedSnapshot.Wire != new Counts(1, 0, 0, 0, 0) || sdkPrewarm.Wire.StreamSql != Workers + || adoPrewarm.Wire.StreamSql != 2 * Workers) throw new InvalidDataException("Prewarm SQL/control counts differ."); + var settling = new List(); + foreach (ArmDefinition arm in Arms) + { + await using var group = await OpenGroupAsync(arm, options, connectionString, ct); + var completed = new int[Workers]; + var errors = new string?[Workers]; + var timer = Stopwatch.StartNew(); + try + { + await RunParallelAsync(async (worker, token) => + { + try { do { await group[worker].ReadAsync(token); completed[worker]++; } while (timer.Elapsed.TotalSeconds < 3); } + catch (Exception error) { errors[worker] = error.ToString(); throw; } + }, ct); + } + finally + { + settling.Add(new { Arm = arm.Id, arm.Api, arm.Lifecycle, Completed = completed, + ElapsedSeconds = timer.Elapsed.TotalSeconds, WorkerFailures = errors }); + Write(output, "settling.json", settling); + } + } + var before = await host.CaptureTimingAsync(ct); + ValidateOwners(before); + Write(output, "pool-proof-before.json", before); + var cpu = new CpuLedger(); + cpu.ObserveServer(before.ServerCpuMs); + for (int round = 0; round < Schedule.Length; round++) + foreach (int index in Schedule[round]) + { + ArmDefinition arm = Arms[index]; + Block block = await MeasureAsync(host, daemonCreationUtc.Value, arm, round + 1, options, connectionString, output, checks, cpu, ct); + blocks.Add(block); + Write(output, "blocks.json", blocks); + Console.WriteLine($"{arm.Id,-14} round{round + 1}: {block.OpsPerSecond:N0} ops/sec; p99 {block.P99Us:N1} us; worst worker p99 {block.WorstWorkerP99Us:N1} us"); + } + var after = await host.CaptureTimingAsync(ct); + ValidateOwners(after); + cpu.ObserveServer(after.ServerCpuMs); + Write(output, "pool-proof-after.json", after); + RecordConditions(output, checks, "AfterAllBlocks", host, daemonCreationUtc); + if (blocks.Count != Schedule.Length * Arms.Length || blocks.Any(x => x.Completed != Count || !x.CpuAccountingValid)) + throw new InvalidDataException("Every fixed block and CPU snapshot is required."); + if (!sources.SequenceEqual(SourceHashes(root)) || !binaries.SequenceEqual(BinaryHashes()) + || head != Git("rev-parse", "HEAD") || status != Git("status", "--porcelain=v1")) + throw new InvalidDataException("Source, runtime, HEAD or working-tree state changed."); + var summaries = Arms.Select(arm => + { + Block[] measured = blocks.Where(x => x.Arm == arm.Id).ToArray(); + double spread = measured.Max(x => x.OpsPerSecond) / measured.Min(x => x.OpsPerSecond) - 1; + return new { Arm = arm.Id, arm.Api, arm.Lifecycle, MedianOpsPerSecond = Median(measured.Select(x => x.OpsPerSecond)), + MedianP99Us = Median(measured.Select(x => x.P99Us)), MedianWorstWorkerP99Us = Median(measured.Select(x => x.WorstWorkerP99Us)), + Spread = spread, Stable = spread <= .25 }; + }).ToArray(); + var ratios = new[] { "SDK", "ADO" }.Select(api => + { + var held = summaries.Single(x => x.Api == api && !x.Lifecycle); + var lifecycle = summaries.Single(x => x.Api == api && x.Lifecycle); + return new { Api = api, LifecycleToHeldOpsPerSecond = lifecycle.MedianOpsPerSecond / held.MedianOpsPerSecond, + LifecycleToHeldP99 = lifecycle.MedianP99Us / held.MedianP99Us, + LifecycleToHeldWorstWorkerP99 = lifecycle.MedianWorstWorkerP99Us / held.MedianWorstWorkerP99Us }; + }).ToArray(); + Write(output, "summary.json", new { Arms = summaries, Ratios = ratios, DiagnosticOnly = true, QualificationEligible = false, + AllocationAccounting = "NotCollected", TotalOperations = blocks.Sum(x => x.Completed), + AllArmsStable = summaries.All(x => x.Stable), SourceAndRuntimeUnchanged = true, + WireCountsValidated = true, CpuAccountingValid = true }); + } + catch (Exception error) { failure = error; } + finally + { + var cleanupErrors = new List(); + if (host is not null) + { + try { await CSharpDbClient.ClearPoolAsync(host.NativeEndpoint.AbsoluteUri).AsTask().WaitAsync(TimeSpan.FromSeconds(20)); } + catch (Exception error) { cleanupErrors.Add(error.ToString()); } + try { await host.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(20)); } + catch (Exception error) { cleanupErrors.Add(error.ToString()); } + // The legacy host owns graceful shutdown. Bound a failure backstop + // to the exact daemon creation identity, without touching other processes. + if (cleanupErrors.Count != 0 && daemonCreationUtc is { } created) + { + try + { + using var daemon = Process.GetProcessById(host.Info.ServerProcessId); + if (daemon.StartTime.ToUniversalTime() == created && !daemon.HasExited) + { + daemon.Kill(entireProcessTree: true); + await daemon.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + } + } + catch (ArgumentException) { /* The owned daemon already exited. */ } + catch (Exception error) { cleanupErrors.Add(error.ToString()); } + } + } + bool same = sources.SequenceEqual(SourceHashes(root)) && binaries.SequenceEqual(BinaryHashes()) + && head == Git("rev-parse", "HEAD") && status == Git("status", "--porcelain=v1"); + Write(output, "cleanup.json", new { HostStarted = host is not null, Host = host?.Info, CleanupErrors = cleanupErrors, + OwnedDaemonExited = host is not null && cleanupErrors.Count == 0, SourceAndRuntimeUnchanged = same, + CompletedBlocks = blocks.Count, CompletedOperations = blocks.Sum(x => x.Completed), PrimaryFailure = failure?.ToString() }); + if (!same) failure ??= new InvalidDataException("Source, runtime, HEAD or working-tree state changed."); + if (cleanupErrors.Count != 0) failure ??= new IOException("Owned pool/daemon cleanup failed; retained in cleanup.json."); + } + if (failure is not null) ExceptionDispatchInfo.Capture(failure).Throw(); + Write(output, "status.json", new { State = "complete", DiagnosticOnly = true, QualificationEligible = false, + AllocationAccounting = "NotCollected", FinishedUtc = DateTimeOffset.UtcNow, Blocks = blocks.Count, + TotalOperations = blocks.Sum(x => x.Completed), CpuAccountingValid = true, SourceAndRuntimeUnchanged = true, + OwnedDaemonExited = true }); + } + + private static async Task MeasureAsync(BenchmarkDaemonHost host, DateTime daemonCreationUtc, ArmDefinition arm, int round, + CSharpDbClientOptions options, string connectionString, string output, List checks, CpuLedger cpu, CancellationToken ct) + { + await using var group = await OpenGroupAsync(arm, options, connectionString, ct); + var warmed = new int[Workers]; + try + { + await RunParallelAsync(async (worker, token) => + { for (int n = 0; n < Warmup; n++) { await group[worker].ReadAsync(token); warmed[worker]++; } }, ct); + } + finally { Write(output, $"{arm.Id}-Round{round}.warmup.json", new { PerWorker = warmed, ExpectedPerWorker = Warmup }); } + RecordConditions(output, checks, $"Before-Round{round}-{arm.Id}", host, daemonCreationUtc); + long[][] starts = Enumerable.Range(0, Workers).Select(_ => new long[PerWorker]).ToArray(); + long[][] ends = Enumerable.Range(0, Workers).Select(_ => new long[PerWorker]).ToArray(); + var completed = new int[Workers]; + var readyTicks = new long[Workers]; + var doneTicks = new long[Workers]; + var errors = new string?[Workers]; + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int readyCount = 0; + using var cancel = CancellationTokenSource.CreateLinkedTokenSource(ct); + async Task Worker(int worker) + { + try + { + readyTicks[worker] = Stopwatch.GetTimestamp(); + if (Interlocked.Increment(ref readyCount) == Workers) ready.TrySetResult(); + await release.Task.WaitAsync(cancel.Token); + for (int n = 0; n < PerWorker; n++) + { + starts[worker][n] = Stopwatch.GetTimestamp(); + await group[worker].ReadAsync(cancel.Token); + ends[worker][n] = Stopwatch.GetTimestamp(); + completed[worker]++; + } + } + catch (Exception error) { errors[worker] = error.ToString(); cancel.Cancel(); throw; } + finally { doneTicks[worker] = Stopwatch.GetTimestamp(); } + } + Task all = Task.WhenAll(Enumerable.Range(0, Workers).Select(Worker)); + using var process = Process.GetCurrentProcess(); + BenchmarkDaemonHost.TimingSnapshot? before = null, after = null; + double? clientBefore = null, clientAfter = null; + long blockStart = 0, blockEnd = 0; + Exception? executionFailure = null, captureFailure = null, cpuFailure = null, drainFailure = null; + try + { + await ready.Task.WaitAsync(ct); + before = await host.CaptureTimingAsync(ct); + clientBefore = process.TotalProcessorTime.TotalMilliseconds; + cpu.Observe(clientBefore.Value, before.ServerCpuMs); + blockStart = Stopwatch.GetTimestamp(); + release.TrySetResult(); + await all.WaitAsync(ct); + blockEnd = Stopwatch.GetTimestamp(); + } + catch (Exception error) + { + executionFailure = error; + cancel.Cancel(); + try { await all.WaitAsync(TimeSpan.FromSeconds(15)); } + catch (Exception drain) { if (!all.IsCompleted) drainFailure = drain; } + } + finally + { + if (blockStart != 0 && blockEnd == 0) blockEnd = Stopwatch.GetTimestamp(); + try + { + using var snapshotDeadline = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + after = await host.CaptureTimingAsync(snapshotDeadline.Token); + clientAfter = process.TotalProcessorTime.TotalMilliseconds; + } + catch (Exception error) { captureFailure = error; } + try + { + if (before is null || after is null || clientBefore is null || clientAfter is null) + throw new InvalidDataException("CPU snapshot missing."); + ValidateCpu(clientBefore.Value, clientAfter.Value, before.ServerCpuMs, after.ServerCpuMs); + cpu.Observe(clientAfter.Value, after.ServerCpuMs); + } + catch (Exception error) { cpuFailure = error; } + bool validCpu = cpuFailure is null && captureFailure is null; + string stem = $"{arm.Id}-Round{round}"; + File.WriteAllLines(Path.Combine(output, stem + ".csv"), new[] { "Worker,Sample,StartTicks,EndTicks,ElapsedTicks" }.Concat( + Enumerable.Range(0, Workers).SelectMany(w => Enumerable.Range(0, completed[w]) + .Select(n => $"{w + 1},{n + 1},{starts[w][n]},{ends[w][n]},{ends[w][n] - starts[w][n]}")))); + Write(output, stem + ".metrics.json", new + { + Arm = arm.Id, arm.Api, arm.Lifecycle, Round = round, Completed = completed.Sum(), Requested = Count, PerWorker = completed, + ReadyTicks = readyTicks, DoneTicks = doneTicks, BlockStart = blockStart, BlockEnd = blockEnd, ElapsedTicks = blockEnd - blockStart, + AllocationAccounting = "NotCollected", ClientCpuBeforeMs = Finite(clientBefore), ClientCpuAfterMs = Finite(clientAfter), + ServerCpuBeforeMs = Finite(before?.ServerCpuMs), ServerCpuAfterMs = Finite(after?.ServerCpuMs), + ClientCpuMs = validCpu ? clientAfter - clientBefore : null, + ServerCpuMs = validCpu ? after!.ServerCpuMs - before!.ServerCpuMs : (double?)null, + CpuMs = validCpu ? clientAfter - clientBefore + after!.ServerCpuMs - before!.ServerCpuMs : null, + CpuAccountingValid = validCpu, + CpuSnapshotsText = new { ClientBefore = clientBefore?.ToString("R", CultureInfo.InvariantCulture), + ClientAfter = clientAfter?.ToString("R", CultureInfo.InvariantCulture), ServerBefore = before?.ServerCpuMs.ToString("R", CultureInfo.InvariantCulture), + ServerAfter = after?.ServerCpuMs.ToString("R", CultureInfo.InvariantCulture) }, + WorkerFailures = errors, ExecutionFailure = executionFailure?.ToString(), CaptureFailure = captureFailure?.ToString(), + CpuFailure = cpuFailure?.ToString(), DrainFailure = drainFailure?.ToString(), WorkersDrained = all.IsCompleted, + PartialAttempts = Enumerable.Range(0, Workers).Where(w => completed[w] < PerWorker && starts[w][completed[w]] != 0) + .Select(w => new { Worker = w + 1, Sample = completed[w] + 1, StartTicks = starts[w][completed[w]], EndTicks = ends[w][completed[w]] }), + }); + Write(output, stem + ".wire.json", new { Before = before?.Wire, After = after?.Wire, HttpBefore = before?.Http, HttpAfter = after?.Http }); + } + if (executionFailure is not null) throw new InvalidOperationException("Block failed; partial results retained, no retry.", executionFailure); + if (captureFailure is not null) throw new IOException("Counter capture failed; samples retained.", captureFailure); + if (cpuFailure is not null) throw new InvalidDataException("CPU accounting failed; raw snapshots retained.", cpuFailure); + if (completed.Any(x => x != PerWorker) || blockEnd <= blockStart || readyTicks.Any(x => x == 0 || x > blockStart) + || doneTicks.Any(x => x < blockStart || x > blockEnd)) throw new InvalidDataException("Worker count/barrier bounds differ."); + for (int w = 0; w < Workers; w++) + for (int n = 0; n < PerWorker; n++) + if (starts[w][n] < blockStart || ends[w][n] <= starts[w][n] || ends[w][n] > doneTicks[w] + || (n > 0 && starts[w][n] < ends[w][n - 1])) throw new InvalidDataException("Sample QPC bounds/order differ."); + ValidateWire(before!.Http, after!.Http, after.Wire - before.Wire, new(arm.Api, "Stream"), Count); + ValidateOwners(after); + long[] samples = Enumerable.Range(0, Workers).SelectMany(w => Enumerable.Range(0, PerWorker) + .Select(n => ends[w][n] - starts[w][n])).Order().ToArray(); + double worst = Enumerable.Range(0, Workers).Max(w => Percentile(Enumerable.Range(0, PerWorker) + .Select(n => ends[w][n] - starts[w][n]).Order().ToArray(), .99)); + double clientCpu = clientAfter!.Value - clientBefore!.Value, serverCpu = after.ServerCpuMs - before.ServerCpuMs; + return new(arm.Id, arm.Api, arm.Lifecycle, round, Count, Count * (double)Stopwatch.Frequency / (blockEnd - blockStart), + Percentile(samples, .5), Percentile(samples, .95), Percentile(samples, .99), worst, clientCpu, serverCpu, clientCpu + serverCpu, true); + } + + private static async Task OpenGroupAsync(ArmDefinition arm, CSharpDbClientOptions options, string connectionString, CancellationToken ct) + { + var runners = new Runner?[Workers]; + try + { + await RunParallelAsync(async (worker, token) => { runners[worker] = await Runner.OpenAsync(arm, options, connectionString, token); }, ct); + return new(runners.Select(x => x!).ToArray()); + } + catch + { + foreach (Runner? runner in runners) + if (runner is not null) try { await runner.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(20)); } catch { } + throw; + } + } + + private static async Task RunParallelAsync(Func operation, CancellationToken ct) + { + using var cancel = CancellationTokenSource.CreateLinkedTokenSource(ct); + async Task Run(int worker) + { + try { await operation(worker, cancel.Token); } + catch { cancel.Cancel(); throw; } + } + Task all = Task.WhenAll(Enumerable.Range(0, Workers).Select(Run)); + try { await all.WaitAsync(ct); } + catch (Exception primary) + { + cancel.Cancel(); + try { await all.WaitAsync(TimeSpan.FromSeconds(15)); } + catch (Exception drain) + { + if (!all.IsCompleted) throw new AggregateException("Sibling workers did not drain within15 seconds.", primary, drain); + } + throw; + } + } + + private sealed class Runner(ArmDefinition arm, CSharpDbClientOptions options, string connectionString) : IAsyncDisposable + { + private ICSharpDbClient? _client; + private CSharpDbConnection? _connection; + private CSharpDbCommand? _command; + internal static async Task OpenAsync(ArmDefinition arm, CSharpDbClientOptions options, string connectionString, CancellationToken ct) + { + var runner = new Runner(arm, options, connectionString); + try + { + if (!arm.Lifecycle) + { + if (arm.Api == "SDK") runner._client = await CSharpDbClient.RentAsync(options, Workers, ct); + else + { + runner._connection = new(connectionString); + await runner._connection.OpenAsync(ct); + runner._command = runner._connection.CreateCommand(); + runner._command.CommandText = Query; + } + } + return runner; + } + catch { await runner.DisposeAsync(); throw; } + } + internal async Task ReadAsync(CancellationToken ct) + { + if (arm.Lifecycle) + { + if (arm.Api == "SDK") + { + await using var client = await CSharpDbClient.RentAsync(options, Workers, ct); + ValidateSdk(await client.ExecuteSqlAsync(Query, ct)); + } + else + { + await using var connection = new CSharpDbConnection(connectionString); + await connection.OpenAsync(ct); + using var command = connection.CreateCommand(); + command.CommandText = Query; + ValidateScalar(await command.ExecuteScalarAsync(ct)); + } + } + else if (_client is not null) ValidateSdk(await _client.ExecuteSqlAsync(Query, ct)); + else ValidateScalar(await _command!.ExecuteScalarAsync(ct)); + } + public async ValueTask DisposeAsync() + { + _command?.Dispose(); + if (_connection is not null) await _connection.DisposeAsync(); + if (_client is not null) await _client.DisposeAsync(); + } + } + + private sealed class RunnerGroup(Runner[] runners) : IAsyncDisposable + { + internal Runner this[int worker] => runners[worker]; + public async ValueTask DisposeAsync() + { + await Task.WhenAll(runners.Select(runner => runner.DisposeAsync().AsTask())).WaitAsync(TimeSpan.FromSeconds(20)); + } + } + + private static void ValidateSdk(SqlExecutionResult result) + { + if (result.Error is not null || !result.IsQuery || result.Rows is not { Count: 1 } || result.Rows[0] is not { Length: 1 } + || result.Rows[0][0] is not long value || value != 42) throw new InvalidDataException("SDK scalar result differs."); + } + private static void ValidateScalar(object? value) + { + if (value is not int scalar || scalar != 42) throw new InvalidDataException("ADO INTEGER scalar must be Int32 value42."); + } + private static void ValidateOwners(BenchmarkDaemonHost.TimingSnapshot snapshot) + { + ValidateCpu(0, 0, 0, snapshot.ServerCpuMs); + long streams = snapshot.Http.Where(x => x.Method.EndsWith("/CommandSession", StringComparison.Ordinal) + && x.Protocol == "HTTP/2" && x.ContentType.StartsWith("application/grpc", StringComparison.Ordinal) + && !x.ContentType.Contains("grpc-web", StringComparison.Ordinal)).Sum(x => x.Value); + if (streams != Workers || snapshot.Http.Sum(x => x.Value) != Workers + 1 || snapshot.Wire.UnarySql != 1 + || snapshot.Wire.UnaryOther != 0 || snapshot.Wire.StreamOther != 2 * Workers + || snapshot.Wire.StreamResponses != snapshot.Wire.StreamSql + 2 * Workers) + throw new InvalidDataException("Expected eight shared streaming owners, one unary seed and no extra control calls."); + } + private static void CheckEnvironment() + { + foreach (string name in new[] { "DOTNET_STARTUP_HOOKS", "CSHARPDB_BENCHMARK_PARENT", "DOTNET_DiagnosticPorts", "DOTNET_DefaultDiagnosticPortSuspend" }) + if (Environment.GetEnvironmentVariable(name) is not null) throw new InvalidOperationException($"Unexpected inherited environment: {name}."); + foreach (string name in new[] { "COR_ENABLE_PROFILING", "CORECLR_ENABLE_PROFILING" }) + if (Environment.GetEnvironmentVariable(name) is { } value && value != "0") throw new InvalidOperationException($"Enabled CLR profiling: {name}."); + if (Debugger.IsAttached) throw new InvalidOperationException("Diagnostic mode must run without an attached debugger."); + } + private static void RecordConditions(string output, List checks, string stage, BenchmarkDaemonHost? host, DateTime? daemonCreationUtc = null) + { + CheckEnvironment(); + using var client = Process.GetCurrentProcess(); + using Process? server = host is null ? null : Process.GetProcessById(host.Info.ServerProcessId); + client.Refresh(); server?.Refresh(); + var record = new { Stage = stage, Utc = DateTimeOffset.UtcNow, Qpc = Stopwatch.GetTimestamp(), + Client = new { Pid = client.Id, CreationUtc = client.StartTime.ToUniversalTime(), Priority = client.PriorityClass.ToString() }, + Server = server is null ? null : new { Pid = server.Id, CreationUtc = server.StartTime.ToUniversalTime(), Priority = server.PriorityClass.ToString() }, + HookEnvironmentAbsent = true, ProfilingEnvironmentDisabled = true }; + checks.Add(record); + Write(output, "priority-checks.json", checks); + if (server is not null && server.StartTime.ToUniversalTime() != daemonCreationUtc) + throw new InvalidOperationException("Owned daemon creation identity changed."); + if (client.PriorityClass != ProcessPriorityClass.Normal || server is not null && server.PriorityClass != ProcessPriorityClass.Normal) + throw new InvalidOperationException("Client and owned daemon must both have Normal process priority."); + } + private static double? Finite(double? value) => value.HasValue && double.IsFinite(value.Value) ? value : null; + private static void ValidateCpu(double clientBefore, double clientAfter, double serverBefore, double serverAfter) + { + if (!double.IsFinite(clientBefore) || clientBefore < 0 || !double.IsFinite(clientAfter) || clientAfter < clientBefore + || !double.IsFinite(serverBefore) || serverBefore < 0 || !double.IsFinite(serverAfter) || serverAfter < serverBefore) + throw new InvalidDataException("CPU snapshots must be finite, nonnegative and monotonic."); + } + private sealed class CpuLedger + { + private double _client, _server; + internal void Observe(double client, double server) { ValidateCpu(_client, client, _server, server); _client = client; _server = server; } + internal void ObserveServer(double server) { ValidateCpu(_client, _client, _server, server); _server = server; } + } + private sealed record ArmDefinition(string Id, string Api, bool Lifecycle); + private sealed record Block(string Arm, string Api, bool Lifecycle, int Round, int Completed, double OpsPerSecond, + double P50Us, double P95Us, double P99Us, double WorstWorkerP99Us, double ClientCpuMs, double ServerCpuMs, double CpuMs, bool CpuAccountingValid) + { public string AllocationAccounting => "NotCollected"; } +} diff --git a/tests/CSharpDB.NetworkBenchmarks/GrpcSessionBenchmark.cs b/tests/CSharpDB.NetworkBenchmarks/GrpcSessionBenchmark.cs new file mode 100644 index 00000000..3821099d --- /dev/null +++ b/tests/CSharpDB.NetworkBenchmarks/GrpcSessionBenchmark.cs @@ -0,0 +1,341 @@ +using System.Diagnostics; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text.Json; +using CSharpDB.Client; +using CSharpDB.Client.Grpc; +using CSharpDB.Data; +using CSharpDB.Testing; +using Grpc.Core; +using Grpc.Core.Interceptors; +using Microsoft.Extensions.DependencyInjection; + +namespace CSharpDB.NetworkBenchmarks; + +/// A fixed, evidence-preserving C1 experiment; never retries a failed block. +internal static class GrpcSessionBenchmark +{ + private const int Iterations = 8192, Rounds = 6, Warmup = 256; + private const string Query = "SELECT value FROM network_read WHERE id = 1"; + private static readonly JsonSerializerOptions Json = new() { WriteIndented = true }; + private static readonly Arm[] Arms = [new("SDK", "Web"), new("SDK", "Native"), new("SDK", "Stream"), + new("ADO", "Web"), new("ADO", "Native"), new("ADO", "Stream")]; + + public static async Task RunAsync(string[] args, bool diagnostics = false, bool isolated = false) + { + string? output = null; + bool ownsOutput = false; + try + { + if (args.Length != 4 || Array.IndexOf(args, "--output") is not (0 or 2) || + Array.IndexOf(args, "--experiment") is not (0 or 2)) + throw new ArgumentException("Use --experiment session --output ; counts and gates are fixed."); + output = Path.GetFullPath(args[Array.IndexOf(args, "--output") + 1]); + string root = Git("rev-parse", "--show-toplevel"); + if (output.Equals(root, StringComparison.OrdinalIgnoreCase) || output.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("Keep generated evidence outside the repository."); + if (Directory.Exists(output)) throw new ArgumentException("The evidence directory must be new."); + Directory.CreateDirectory(output); + ownsOutput = true; + using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(5)); + await RunCoreAsync(root, output, diagnostics, isolated, timeout.Token); + return 0; + } + catch (Exception error) + { + Console.Error.WriteLine(error); + if (ownsOutput && output is not null) + { + File.AppendAllText(Path.Combine(output, "failures.jsonl"), JsonSerializer.Serialize(new { Utc = DateTimeOffset.UtcNow, Error = error.ToString() }) + Environment.NewLine); + Write(output, "status.json", new { State = "failed", FinishedUtc = DateTimeOffset.UtcNow }); + } + return 1; + } + } + + private static async Task RunCoreAsync(string root, string output, bool diagnostics, bool isolated, CancellationToken ct) + { + var sources = SourceHashes(root); + var binaries = BinaryHashes(); + string status = Git("status", "--porcelain=v1"); + Write(output, "protocol.json", new + { + Version = isolated ? 4 : diagnostics ? 3 : 2, Experiment = isolated ? "persistent-command-session-isolated-C1" : diagnostics ? "persistent-command-session-diagnostic" : "persistent-command-session-C1", Iterations, Rounds, Warmup, + DiagnosticScope = diagnostics ? "DIAGNOSTIC ONLY: extra stage timing, dedicated-thread ThreadPool dispatch sampling, EventPipe GC/threading trace. No performance qualification or promotion. Original failed qualification retained unchanged. No thread/GC/OS tuning." : null, + SeparateProcesses = isolated, + HarnessRevision = "Version 1 stopped in ADO settling with zero timed blocks: it incorrectly required Int64 for an INTEGER scalar mapped by ADO to Int32. Version 2 accepts Int32/Int64 value 42; fixed counts, order, and gates are unchanged. Preserve version 1 evidence.", + SettlingSecondsPerArm = 3, Query, Arms, + Order = "Six Williams rounds, base indices 0,1,5,2,4,3 shifted by round modulo six. Every arm occupies every position once. No retries or exclusions.", + Scope = $"Actual daemon, {(isolated ? "separate client/server processes" : "same process")}, cleartext loopback, warm one-row point lookup, one outstanding command. SDK and ADO.NET rent from the same pool per wire mode, Max Pool Size=1. Rental/open and warmup are outside timing. No TLS, remote-host, concurrency, pipelining, batching, or prepared-handle claim.", + IsolationProtocol = isolated ? "New deployment boundary, not a retry of same-process qualification: one owned child hosts the actual daemon, client remains in parent. Separate heaps and ThreadPools. No profiler or stage probes. Every arm uses the same child. Snapshot IPC is outside timing. Allocations/CPU include both processes and bounded snapshot overhead. Preserve prior failed same-process qualification unchanged." : null, + Gate = "Per API: stream and both baselines must each pass max/min block throughput spread <=25%; stream median throughput >=105% of each baseline, wins >=5 of six paired rounds against each, median block p99 <= each baseline. Otherwise descriptive only; no retry.", + Validation = "Every result equals 42. Per timed block: exactly 8192 SQL requests/responses, zero other commands, unary Native uses HTTP/2 application/grpc, Web uses HTTP/1.1 application/grpc-web; stream has zero new HTTP RPCs and exactly 8192 decoded ExecuteSql frames and responses. All raw QPC samples retained.", + Runtime = RuntimeInformation.FrameworkDescription, OS = RuntimeInformation.OSDescription, + Environment.MachineName, Environment.ProcessorCount, Architecture = RuntimeInformation.ProcessArchitecture.ToString(), + System.Runtime.GCSettings.IsServerGC, Stopwatch.Frequency, + GitHead = Git("rev-parse", "HEAD"), GitStatus = status, Sources = sources, Binaries = binaries, + }); + File.WriteAllText(Path.Combine(output, "source.patch"), Git("diff", "--binary")); + foreach (var file in sources) + { + string copy = Path.Combine(output, "source", file.Name); + Directory.CreateDirectory(Path.GetDirectoryName(copy)!); + File.Copy(Path.Combine(root, file.Name), copy); + } + Write(output, "status.json", new { State = "running", StartedUtc = DateTimeOffset.UtcNow }); + var blocks = new List(); + using var probe = diagnostics ? new SessionDiagnosticProbe() : null; + await using var host = await BenchmarkDaemonHost.StartAsync(isolated, probe, ct); + Write(output, "host-info.json", host.Info); + try + { + await using (var seed = CSharpDbClient.Create(new() { Transport = CSharpDbTransport.Grpc, GrpcMode = CSharpDbGrpcMode.Native, Endpoint = host.NativeEndpoint.AbsoluteUri })) + { + var result = await seed.ExecuteSqlAsync("CREATE TABLE network_read (id INTEGER PRIMARY KEY, value INTEGER); INSERT INTO network_read VALUES (1, 42)", ct); + if (result.Error is not null) throw new InvalidOperationException(result.Error); + } + var settling = new List(); + foreach (var arm in Arms) + { + await using var runner = await Runner.OpenAsync(host, arm, ct); + var timer = Stopwatch.StartNew(); + int completed = 0; + do { await runner.ReadAsync(ct); completed++; } while (timer.Elapsed < TimeSpan.FromSeconds(3)); + settling.Add(new { arm.Api, arm.Wire, Completed = completed, timer.Elapsed.TotalSeconds }); + Write(output, "settling.json", settling); + } + int[] order = [0, 1, 5, 2, 4, 3]; + for (int round = 0; round < Rounds; round++) + foreach (int position in order) + { + var block = await MeasureAsync(host, probe, Arms[(position + round) % Arms.Length], round + 1, output, ct); + blocks.Add(block); + Write(output, "blocks.json", blocks); + Console.WriteLine($"{block.Api,-3} {block.Wire,-6} round {block.Round}: {block.OpsPerSecond:N0} ops/sec; p99 {block.P99Us:N1} us"); + } + if (!sources.SequenceEqual(SourceHashes(root)) || !binaries.SequenceEqual(BinaryHashes()) || status != Git("status", "--porcelain=v1")) + throw new InvalidOperationException("Source, binary, or working-tree state changed during the run."); + var summaries = blocks.GroupBy(x => new Arm(x.Api, x.Wire)).Select(g => new Summary(g.Key.Api, g.Key.Wire, + Median(g.Select(x => x.OpsPerSecond)), Median(g.Select(x => x.P99Us)), + g.Max(x => x.OpsPerSecond) / g.Min(x => x.OpsPerSecond) - 1, + Median(g.Select(x => x.AllocatedBytes / (double)Iterations)))).ToArray(); + var gates = new List(); + foreach (string api in new[] { "SDK", "ADO" }) + { + Summary stream = summaries.Single(x => x.Api == api && x.Wire == "Stream"); + foreach (string baseline in new[] { "Web", "Native" }) + { + Summary other = summaries.Single(x => x.Api == api && x.Wire == baseline); + int wins = blocks.Where(x => x.Api == api && x.Wire == "Stream") + .Count(x => x.OpsPerSecond > blocks.Single(b => b.Api == api && b.Wire == baseline && b.Round == x.Round).OpsPerSecond); + bool stable = stream.Spread <= .25 && other.Spread <= .25; + double change = stream.MedianOpsPerSecond / other.MedianOpsPerSecond - 1; + bool passed = !diagnostics && stable && change >= .05 && wins >= 5 && stream.MedianP99Us <= other.MedianP99Us; + gates.Add(new { Api = api, Baseline = baseline, ThroughputChange = change, PairedWins = wins, + Stable = stable, P99Ratio = stream.MedianP99Us / other.MedianP99Us, Passed = passed }); + } + } + Write(output, "summary.json", new { Summaries = summaries, Gates = gates, TotalOperations = blocks.Sum(x => x.Completed), + SourcesAndBinariesUnchanged = true, WireCountsValidated = true, DiagnosticOnly = diagnostics }); + probe?.Save(output); + Write(output, "status.json", new { State = "complete", FinishedUtc = DateTimeOffset.UtcNow, Blocks = blocks.Count, + TotalOperations = blocks.Sum(x => x.Completed) }); + } + finally + { + await CSharpDbClient.ClearPoolAsync(host.CompatibilityEndpoint.AbsoluteUri); + await CSharpDbClient.ClearPoolAsync(host.NativeEndpoint.AbsoluteUri); + } + } + + private static async Task MeasureAsync(BenchmarkDaemonHost host, SessionDiagnosticProbe? probe, Arm arm, int round, string output, CancellationToken ct) + { + await using var runner = await Runner.OpenAsync(host, arm, ct); + for (int i = 0; i < Warmup; i++) await runner.ReadAsync(ct); + var before = await host.CaptureAsync(ct); + var beforeHttp = before.Http; + var beforeWire = before.Wire; + var ticks = new long[Iterations]; + using var process = Process.GetCurrentProcess(); + long allocations = GC.GetTotalAllocatedBytes(true) + before.ServerAllocatedBytes; + double cpu = process.TotalProcessorTime.TotalMilliseconds + before.ServerCpuMs; + BenchmarkDaemonHost.Snapshot? after = null; + int completed = 0; + Exception? commandFailure = null; + probe?.BeginBlock(arm.Api, arm.Wire, round, Iterations); + long start = Stopwatch.GetTimestamp(), end = start; + try + { + for (; completed < Iterations; completed++) + { + long sample = Stopwatch.GetTimestamp(); + probe?.BeginSample(completed, sample); + await runner.ReadAsync(ct); + long sampleEnd = Stopwatch.GetTimestamp(); + ticks[completed] = sampleEnd - sample; + probe?.EndSample(completed, sampleEnd); + } + } + catch (Exception error) { commandFailure = error; throw; } + finally + { + end = Stopwatch.GetTimestamp(); + probe?.EndBlock(); + Exception? captureFailure = null; + try { after = await host.CaptureAsync(ct); } + catch (Exception error) { captureFailure = error; } + allocations = GC.GetTotalAllocatedBytes(true) + (after?.ServerAllocatedBytes ?? before.ServerAllocatedBytes) - allocations; + cpu = process.TotalProcessorTime.TotalMilliseconds + (after?.ServerCpuMs ?? before.ServerCpuMs) - cpu; + string stem = $"{arm.Api}-{arm.Wire}-{round}"; + File.WriteAllLines(Path.Combine(output, stem + ".csv"), new[] { "Sample,ElapsedTicks" } + .Concat(ticks.Take(completed).Select((value, index) => $"{index + 1},{value}"))); + Write(output, stem + ".wire.json", new { Before = beforeWire, After = after?.Wire, + HttpBefore = beforeHttp, HttpAfter = after?.Http, Completed = completed }); + Write(output, stem + ".metrics.json", new { Completed = completed, Requested = Iterations, ElapsedTicks = end - start, + AllocatedBytes = allocations, CpuMs = cpu, CommandFailure = commandFailure?.ToString(), CaptureFailure = captureFailure?.ToString() }); + if (probe is not null) await probe.SaveBlockAsync(output, stem, completed, ct); + if (captureFailure is not null && commandFailure is null) + throw new IOException("Could not capture daemon counters; raw samples retained.", captureFailure); + } + ValidateWire(beforeHttp, after!.Http, after.Wire - beforeWire, arm); + Array.Sort(ticks); + return new Block(arm.Api, arm.Wire, round, completed, completed * (double)Stopwatch.Frequency / (end - start), + Percentile(ticks, .50), Percentile(ticks, .95), Percentile(ticks, .99), allocations, cpu); + } + + internal static void ValidateWire(BenchmarkDaemonHost.HttpCount[] before, BenchmarkDaemonHost.HttpCount[] after, Counts delta, Arm arm, int count = Iterations) + { + long http = 0; + foreach (var pair in after) + { + long change = pair.Value - (before.SingleOrDefault(x => x.Method == pair.Method && x.Protocol == pair.Protocol && x.ContentType == pair.ContentType)?.Value ?? 0); + if (change == 0) continue; + if (arm.Wire == "Stream" || pair.Protocol != (arm.Wire == "Web" ? "HTTP/1.1" : "HTTP/2") || + !pair.ContentType.StartsWith(arm.Wire == "Web" ? "application/grpc-web" : "application/grpc", StringComparison.Ordinal) || + (arm.Wire != "Web" && pair.ContentType.Contains("grpc-web", StringComparison.Ordinal)) || + !pair.Method.EndsWith("/ExecuteSql", StringComparison.Ordinal)) + throw new InvalidOperationException($"Unexpected timed HTTP request: {pair}"); + http += change; + } + var expected = arm.Wire == "Stream" ? new Counts(0, 0, count, 0, count) : new Counts(count, 0, 0, 0, 0); + if (delta != expected || http != (arm.Wire == "Stream" ? 0 : count)) + throw new InvalidOperationException($"Unexpected command counts: {delta}; expected {expected}; HTTP {http}."); + } + + internal sealed class Runner : IAsyncDisposable + { + private ICSharpDbClient? _client; + private CSharpDbConnection? _connection; + private CSharpDbCommand? _command; + public static async Task OpenAsync(BenchmarkDaemonHost host, Arm arm, CancellationToken ct, int maxPoolSize = 1) + { + var runner = new Runner(); + var mode = arm.Wire == "Web" ? CSharpDbGrpcMode.Auto : CSharpDbGrpcMode.Native; + var calls = arm.Wire == "Stream" ? CSharpDbGrpcCallMode.Streaming : CSharpDbGrpcCallMode.Unary; + try + { + if (arm.Api == "SDK") + runner._client = await CSharpDbClient.RentAsync(new() { Transport = CSharpDbTransport.Grpc, GrpcMode = mode, + GrpcCallMode = calls, Endpoint = host.EndpointFor(mode).AbsoluteUri }, maxPoolSize, ct); + else + { + runner._connection = new($"Transport=Grpc;Grpc Mode={mode};Grpc Call Mode={calls};Endpoint={host.EndpointFor(mode)};Pooling=true;Max Pool Size={maxPoolSize}"); + await runner._connection.OpenAsync(ct); + runner._command = runner._connection.CreateCommand(); + runner._command.CommandText = Query; + } + return runner; + } + catch { await runner.DisposeAsync(); throw; } + } + public async Task ReadAsync(CancellationToken ct) + { + if (_client is not null) + { + var result = await _client.ExecuteSqlAsync(Query, ct); + if (result.Error is not null || result.Rows is not { Count: 1 } || result.Rows[0].Length != 1 || result.Rows[0][0] is not long value || value != 42) + throw new InvalidOperationException("The SDK query returned the wrong result."); + } + else + { + object? scalar = await _command!.ExecuteScalarAsync(ct); + if (scalar is not (int or long) || Convert.ToInt64(scalar, CultureInfo.InvariantCulture) != 42) + throw new InvalidOperationException($"The ADO.NET query returned the wrong result: {scalar} ({scalar?.GetType().Name})."); + } + } + public async ValueTask DisposeAsync() + { + _command?.Dispose(); + if (_connection is not null) await _connection.DisposeAsync(); + if (_client is not null) await _client.DisposeAsync(); + } + } + + internal sealed class WireCounters : Interceptor + { + private long _unarySql, _unaryOther, _streamSql, _streamOther, _responses; + public Counts Snapshot() => new(Interlocked.Read(ref _unarySql), Interlocked.Read(ref _unaryOther), + Interlocked.Read(ref _streamSql), Interlocked.Read(ref _streamOther), Interlocked.Read(ref _responses)); + public override Task UnaryServerHandler(TRequest request, ServerCallContext context, UnaryServerMethod continuation) + { + if (context.Method.EndsWith("/ExecuteSql", StringComparison.Ordinal)) Interlocked.Increment(ref _unarySql); + else Interlocked.Increment(ref _unaryOther); + return continuation(request, context); + } + public override Task DuplexStreamingServerHandler(IAsyncStreamReader reader, + IServerStreamWriter writer, ServerCallContext context, DuplexStreamingServerMethod continuation) + => continuation(new Reader(reader, this), new Writer(writer, this), context); + private sealed class Reader(IAsyncStreamReader inner, WireCounters owner) : IAsyncStreamReader + { + public T Current => inner.Current; + public async Task MoveNext(CancellationToken ct) + { + if (!await inner.MoveNext(ct)) return false; + if (Current is CommandSessionRequest { ExecuteSql: not null }) Interlocked.Increment(ref owner._streamSql); + else Interlocked.Increment(ref owner._streamOther); + return true; + } + } + private sealed class Writer(IServerStreamWriter inner, WireCounters owner) : IServerStreamWriter + { + public WriteOptions? WriteOptions { get => inner.WriteOptions; set => inner.WriteOptions = value; } + public Task WriteAsync(T message) { Interlocked.Increment(ref owner._responses); return inner.WriteAsync(message); } + } + } + internal sealed record Counts(long UnarySql, long UnaryOther, long StreamSql, long StreamOther, long StreamResponses) + { + public static Counts operator -(Counts a, Counts b) => new(a.UnarySql - b.UnarySql, a.UnaryOther - b.UnaryOther, + a.StreamSql - b.StreamSql, a.StreamOther - b.StreamOther, a.StreamResponses - b.StreamResponses); + } + internal static Hash[] SourceHashes(string root) => Git("ls-files", "-z", "--cached", "--others", "--exclude-standard", + "src", "tests", "Directory.Build.props", "Directory.Build.targets", "global.json") + .Split('\0', StringSplitOptions.RemoveEmptyEntries).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal) + .Where(path => File.Exists(Path.Combine(root, path))).Select(path => new Hash(path, + Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(Path.Combine(root, path)))))).ToArray(); + internal static Hash[] BinaryHashes() => Directory.EnumerateFiles(AppContext.BaseDirectory).Where(path => + path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".deps.json", StringComparison.OrdinalIgnoreCase) || + path.EndsWith(".runtimeconfig.json", StringComparison.OrdinalIgnoreCase)).Order(StringComparer.Ordinal) + .Select(path => new Hash(Path.GetFileName(path), Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))))).ToArray(); + internal static string Git(params string[] args) + { + var info = new ProcessStartInfo("git") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; + foreach (string argument in args) info.ArgumentList.Add(argument); + using var process = Process.Start(info)!; + string output = process.StandardOutput.ReadToEnd(), error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + if (process.ExitCode != 0) throw new InvalidOperationException(error); + return output.TrimEnd(); + } + internal static void Write(string output, string name, object value) => File.WriteAllText(Path.Combine(output, name), JsonSerializer.Serialize(value, Json)); + internal static double Percentile(long[] values, double quantile) => values[(int)Math.Ceiling(values.Length * quantile) - 1] * 1_000_000.0 / Stopwatch.Frequency; + internal static double Median(IEnumerable values) + { + double[] sorted = values.Order().ToArray(); + return (sorted[(sorted.Length - 1) / 2] + sorted[sorted.Length / 2]) / 2; + } + internal sealed record Arm(string Api, string Wire); + internal sealed record Hash(string Name, string Sha256); + private sealed record Summary(string Api, string Wire, double MedianOpsPerSecond, double MedianP99Us, double Spread, double BytesPerOp); + private sealed record Block(string Api, string Wire, int Round, int Completed, double OpsPerSecond, double P50Us, double P95Us, double P99Us, long AllocatedBytes, double CpuMs); +} diff --git a/tests/CSharpDB.NetworkBenchmarks/NetworkBenchmarkProgram.cs b/tests/CSharpDB.NetworkBenchmarks/NetworkBenchmarkProgram.cs new file mode 100644 index 00000000..465415ab --- /dev/null +++ b/tests/CSharpDB.NetworkBenchmarks/NetworkBenchmarkProgram.cs @@ -0,0 +1,553 @@ +using System.Diagnostics; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text.Json; +using CSharpDB.Client; +using CSharpDB.Data; +using CSharpDB.Testing; + +namespace CSharpDB.NetworkBenchmarks; + +internal static class NetworkBenchmarkProgram +{ + private const string Query = "SELECT value FROM network_read WHERE id = 1"; + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + public static async Task Main(string[] args) + { + int experimentIndex = Array.IndexOf(args, "--experiment"); + if (args is ["--session-daemon"]) return await BenchmarkDaemonHost.RunChildAsync(); + if (experimentIndex >= 0 && experimentIndex + 1 < args.Length && args[experimentIndex + 1] == "pooled-allocation-profile") + return await GrpcPooledAllocationProfile.RunAsync(args); + if (experimentIndex >= 0 && experimentIndex + 1 < args.Length && args[experimentIndex + 1] == "pooled-lifecycle-c8") + return await GrpcPooledLifecycleBenchmark.RunAsync(args); + if (experimentIndex >= 0 && experimentIndex + 1 < args.Length && args[experimentIndex + 1] == "session-c8-baseline-timeline") + return await GrpcConcurrentSessionBenchmark.RunBaselineTimelineAsync(args); + if (experimentIndex >= 0 && experimentIndex + 1 < args.Length && args[experimentIndex + 1] == "session-c8-timing") + return await GrpcConcurrentSessionBenchmark.RunTimingAsync(args); + if (experimentIndex >= 0 && experimentIndex + 1 < args.Length && args[experimentIndex + 1] == "session-c8-timing-profile") + return await GrpcConcurrentSessionBenchmark.RunTimingAsync(args, true); + if (experimentIndex >= 0 && experimentIndex + 1 < args.Length && args[experimentIndex + 1] == "session-c8-paired") + return await GrpcConcurrentSessionBenchmark.RunPairedAsync(args); + if (experimentIndex >= 0 && experimentIndex + 1 < args.Length && args[experimentIndex + 1] == "session-c8-paired-timing") + return await GrpcConcurrentSessionBenchmark.RunPairedTimingAsync(args); + if (experimentIndex >= 0 && experimentIndex + 1 < args.Length && args[experimentIndex + 1] == "session-c8-paired-long-timing") + return await GrpcConcurrentSessionBenchmark.RunLongPairedTimingAsync(args); + if (experimentIndex >= 0 && experimentIndex + 1 < args.Length && args[experimentIndex + 1] is "session-c8" or "session-c8-profile") + return await GrpcConcurrentSessionBenchmark.RunAsync(args, args[experimentIndex + 1] == "session-c8-profile"); + if (experimentIndex >= 0 && experimentIndex + 1 < args.Length && args[experimentIndex + 1] is "session" or "session-diagnostic" or "session-isolated") + return await GrpcSessionBenchmark.RunAsync(args, args[experimentIndex + 1] == "session-diagnostic", args[experimentIndex + 1] == "session-isolated"); + if (args.Length == 0 || args.Contains("--help")) + { + Console.WriteLine("Usage: --output [--experiment native|pooling|shared] [--iterations 2000] [--open-iterations 300] [--rounds 4] [--settle-seconds 10]. Shared compares SDK/ADO, Auto/Native, fresh/pooled/reused with 1000 iterations and twelve balanced rounds. --experiment session --output runs the fixed six-arm persistent-session experiment. --experiment session-c8-timing --output runs fixed C8 timing/CPU qualification without allocation-counter collection."); + return args.Contains("--help") ? 0 : 1; + } + string? output = null; + bool ownsOutput = false; + try + { + var options = Parse(args); + output = Path.GetFullPath(options.Output); + string root = Path.GetFullPath(Git("rev-parse", "--show-toplevel")); + if (output.Equals(root, StringComparison.OrdinalIgnoreCase) || + output.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("Keep generated evidence outside the repository."); + if (Directory.Exists(output)) + throw new InvalidOperationException("The output directory must be new; existing evidence is never overwritten."); + Directory.CreateDirectory(output); + ownsOutput = true; + await RunAsync(options, output); + return 0; + } + catch (Exception error) + { + if (ownsOutput && output is not null) + { + await File.AppendAllTextAsync(Path.Combine(output, "failures.jsonl"), + JsonSerializer.Serialize(new { Utc = DateTimeOffset.UtcNow, Error = error.ToString() }) + Environment.NewLine); + WriteJson(output, "status.json", new { State = "failed", FinishedUtc = DateTimeOffset.UtcNow }); + } + Console.Error.WriteLine(error); + return 1; + } + } + + private static async Task RunAsync(Options options, string output) + { + bool shared = options.Experiment == "shared"; + using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(shared ? 5 : 3)); + var ct = timeout.Token; + var binaries = HashBinaries(); + var sources = HashSources(); + bool pooling = options.Experiment == "pooling"; + WriteJson(output, "protocol.json", new + { + Version = shared ? 5 : pooling ? 3 : 2, options.Experiment, options.Iterations, options.OpenIterations, options.Rounds, options.SettleSeconds, + Warmup = 100, OpenWarmup = 25, + Order = shared + ? "12 cases: SDK reused, ADO reused, SDK fresh, ADO fresh, SDK pooled, ADO pooled; Auto then Native for each. Twelve Williams-design rounds use base case indices 0,1,11,2,10,3,9,4,8,5,7,6 shifted by round modulo 12. Every case occupies every position once. No retries." + : pooling + ? "Six balanced permutations of unpooled(U), pooled(P), reused(R): UPR, PRU, RUP, RPU, PUR, URP. All use Auto and the same iteration count. No retries." + : "Even rounds Auto then Native; odd rounds Native then Auto. No retries.", + Query, GitHead = Git("rev-parse", "HEAD"), GitStatus = Git("status", "--porcelain=v1"), + Runtime = RuntimeInformation.FrameworkDescription, OS = RuntimeInformation.OSDescription, + Environment.MachineName, Architecture = RuntimeInformation.ProcessArchitecture.ToString(), + System.Runtime.GCSettings.IsServerGC, + Environment.ProcessorCount, Stopwatch.Frequency, Binaries = binaries, Sources = sources, + Scope = shared + ? "Pilot: SDK and ADO.NET, Auto gRPC-Web/HTTP1 and Native gRPC/HTTP2, reused/fresh/pooled lifetimes. Same daemon, cleartext loopback, warm database, same process, one outstanding operation. Shared pool Max Pool Size=1. No TLS, prepared handles, separate-host or saturation claim." + : pooling + ? "Pilot: existing Auto gRPC-Web/HTTP1, cleartext loopback, actual daemon, shared warm database, one outstanding request, same-process client/server. Compare unpooled open/read/close, pooled open/read/close (Max Pool Size=1), and already-open. No prepared handles. Not TLS, separate-host, or saturation." + : "Pilot: cleartext loopback, actual daemon, shared warm database, one outstanding request, client and server in the same process. No pooling or prepared handles. Not a TLS, separate-host, or saturation result.", + Metrics = "Per-call client latency, sequential operations/sec, process CPU and allocations including client and server. Raw samples retained.", + StabilityScreen = "Every scenario/mode must have (maximum block throughput / minimum block throughput - 1) <= 25%. Failure leaves the result descriptive; there are no retries.", + PoolingGate = pooling ? "All three arms pass stability; pooled median throughput >= 1.25 times unpooled; pooled median block p99 <= unpooled. This is a pilot gate, not production qualification." : null, + SharedPoolGate = shared ? "All 12 cases pass the 25% stability screen. For both APIs and both modes, pooled median throughput >= 1.25 times fresh and pooled median block p99 <= fresh. No retries or promotion from failed gates." : null, + DeadlineSeconds = shared ? 300 : 180, + FreshClientBudget = shared ? "Fresh SDK/ADO cases use 100 settling operations and OpenIterations per block (default 100); reused/pooled cases retain timed settling and Iterations per block. Fresh per-block warmup is 25. Bound total fresh connections below the Windows 16384-port range. Version 4 exhausted ports during unlimited fresh settling before any timed block; preserve it separately." : null, + }); + await File.WriteAllTextAsync(Path.Combine(output, "source.patch"), Git("diff", "--binary"), ct); + string repoRoot = Git("rev-parse", "--show-toplevel"); + foreach (string path in Git("ls-files", "-z", "--others", "--exclude-standard", "src/CSharpDB.Client", "src/CSharpDB.Data", "tests/CSharpDB.Data.Tests", "tests/CSharpDB.Daemon.Tests", "tests/Shared", "tests/CSharpDB.NetworkBenchmarks") + .Split('\0', StringSplitOptions.RemoveEmptyEntries)) + { + string destination = Path.Combine(output, "new-source", path); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + File.Copy(Path.Combine(repoRoot, path), destination); + } + WriteJson(output, "status.json", new { State = "running", StartedUtc = DateTimeOffset.UtcNow }); + + await using var host = new GrpcSocketDaemonFixture(); + await using (var seed = host.CreateClient(CSharpDbGrpcMode.Native)) + { + Check(await seed.ExecuteSqlAsync( + "CREATE TABLE network_read (id INTEGER PRIMARY KEY, value INTEGER); INSERT INTO network_read VALUES (1, 42)", ct)); + } + + var rows = new List(); + var settling = new List(); + try + { + if (shared) + { + var cases = new[] { "sdk-reused", "ado-reused", "sdk-create-read-dispose", "ado-open-read-close", "sdk-pooled-read-dispose", "ado-pooled-open-read-close" } + .SelectMany(scenario => new[] { CSharpDbGrpcMode.Auto, CSharpDbGrpcMode.Native }.Select(mode => new BenchmarkCase(scenario, mode))).ToArray(); + foreach (var benchmark in cases) + { + settling.Add(await SettleAsync(host, benchmark.Scenario, benchmark.Mode, + IsFresh(benchmark.Scenario) ? 0 : options.SettleSeconds, ct)); + WriteJson(output, "settling.json", settling); + } + int[] baseOrder = [0, 1, 11, 2, 10, 3, 9, 4, 8, 5, 7, 6]; + for (int round = 0; round < 12; round++) + foreach (int index in baseOrder) + { + var benchmark = cases[(index + round) % cases.Length]; + int count = IsFresh(benchmark.Scenario) ? options.OpenIterations : options.Iterations; + var row = await MeasureAsync(host, benchmark.Scenario, benchmark.Mode, round + 1, count, output, ct); + rows.Add(row); + WriteJson(output, "blocks.json", rows); + Console.WriteLine($"{row.Scenario,-27} {row.Mode,-6} round {row.Round}: {row.OperationsPerSecond:N0} ops/sec; p99 {row.P99Us:N1} us"); + } + } + else if (pooling) + { + foreach (string scenario in new[] { "ado-open-read-close", "ado-pooled-open-read-close", "ado-reused" }) + { + settling.Add(await SettleAsync(host, scenario, CSharpDbGrpcMode.Auto, options.SettleSeconds, ct)); + WriteJson(output, "settling.json", settling); + } + string[][] orders = + [ + ["ado-open-read-close", "ado-pooled-open-read-close", "ado-reused"], + ["ado-pooled-open-read-close", "ado-reused", "ado-open-read-close"], + ["ado-reused", "ado-open-read-close", "ado-pooled-open-read-close"], + ["ado-reused", "ado-pooled-open-read-close", "ado-open-read-close"], + ["ado-pooled-open-read-close", "ado-open-read-close", "ado-reused"], + ["ado-open-read-close", "ado-reused", "ado-pooled-open-read-close"], + ]; + for (int round = 0; round < orders.Length; round++) + foreach (string scenario in orders[round]) + { + var row = await MeasureAsync(host, scenario, CSharpDbGrpcMode.Auto, round + 1, options.Iterations, output, ct); + rows.Add(row); + WriteJson(output, "blocks.json", rows); + Console.WriteLine($"{scenario,-27} round {round + 1}: {row.OperationsPerSecond:N0} ops/sec; p50 {row.P50Us:N1} us; p99 {row.P99Us:N1} us"); + } + } + else foreach (string scenario in new[] { "sdk-reused", "ado-reused", "ado-open-read-close" }) + { + foreach (var mode in new[] { CSharpDbGrpcMode.Auto, CSharpDbGrpcMode.Native }) + { + settling.Add(await SettleAsync(host, scenario, mode, options.SettleSeconds, ct)); + WriteJson(output, "settling.json", settling); + } + int iterations = scenario == "ado-open-read-close" ? options.OpenIterations : options.Iterations; + for (int round = 0; round < options.Rounds; round++) + { + CSharpDbGrpcMode[] modes = round % 2 == 0 + ? [CSharpDbGrpcMode.Auto, CSharpDbGrpcMode.Native] + : [CSharpDbGrpcMode.Native, CSharpDbGrpcMode.Auto]; + foreach (var mode in modes) + { + var row = await MeasureAsync(host, scenario, mode, round + 1, iterations, output, ct); + rows.Add(row); + // Persist each finished block before attempting the next one. + WriteJson(output, "blocks.json", rows); + Console.WriteLine($"{scenario,-20} {mode,-6} round {round + 1}: {row.OperationsPerSecond:N0} ops/sec; p50 {row.P50Us:N1} us; p99 {row.P99Us:N1} us"); + } + } + } + + if (!binaries.SequenceEqual(HashBinaries())) + throw new InvalidOperationException("A measured binary changed during the run."); + if (!sources.SequenceEqual(HashSources())) + throw new InvalidOperationException("A source file changed during the run."); + var summary = rows.GroupBy(row => new { row.Scenario, row.Mode }).Select(group => new + { + group.Key.Scenario, group.Key.Mode, + MedianOperationsPerSecond = Median(group.Select(row => row.OperationsPerSecond)), + MedianP50Us = Median(group.Select(row => row.P50Us)), + MedianP95Us = Median(group.Select(row => row.P95Us)), + MedianP99Us = Median(group.Select(row => row.P99Us)), + ThroughputSpreadPercent = 100 * (group.Max(row => row.OperationsPerSecond) / group.Min(row => row.OperationsPerSecond) - 1), + }).ToArray(); + WriteJson(output, "summary.json", summary); + WriteJson(output, "requests.json", host.Snapshot().Select(pair => new { pair.Key, Count = pair.Value })); + var unpooled = summary.SingleOrDefault(row => row.Scenario == "ado-open-read-close" && row.Mode == "Auto"); + var pooled = summary.SingleOrDefault(row => row.Scenario == "ado-pooled-open-read-close" && row.Mode == "Auto"); + var comparisons = shared ? new[] { "sdk", "ado" }.SelectMany(api => new[] { "Auto", "Native" }.Select(mode => + { + var fresh = summary.Single(row => row.Mode == mode && row.Scenario == (api == "sdk" ? "sdk-create-read-dispose" : "ado-open-read-close")); + var rented = summary.Single(row => row.Mode == mode && row.Scenario == (api == "sdk" ? "sdk-pooled-read-dispose" : "ado-pooled-open-read-close")); + return new + { + Api = api, Mode = mode, + ThroughputRatio = rented.MedianOperationsPerSecond / fresh.MedianOperationsPerSecond, + PooledP99NoWorse = rented.MedianP99Us <= fresh.MedianP99Us, + }; + })).ToArray() : null; + WriteJson(output, "assessment.json", new + { + StabilityPassed = summary.All(row => row.ThroughputSpreadPercent <= 25), + PoolingGatePassed = pooling ? (bool?)(summary.All(row => row.ThroughputSpreadPercent <= 25) && + pooled!.MedianOperationsPerSecond >= 1.25 * unpooled!.MedianOperationsPerSecond && + pooled.MedianP99Us <= unpooled.MedianP99Us) : null, + PooledThroughputChangePercent = pooled is not null ? (double?)(100 * (pooled.MedianOperationsPerSecond / unpooled!.MedianOperationsPerSecond - 1)) : null, + SharedPoolingComparisons = comparisons, + SharedPoolGatePassed = shared ? (bool?)(summary.All(row => row.ThroughputSpreadPercent <= 25) && comparisons!.All(row => row.ThroughputRatio >= 1.25 && row.PooledP99NoWorse)) : null, + Scope = "Loopback sequential pilot; passing stability does not establish separate-host or TLS performance.", + }); + WriteJson(output, "status.json", new { State = "complete", FinishedUtc = DateTimeOffset.UtcNow, Blocks = rows.Count }); + Console.WriteLine($"Evidence: {output}"); + } + finally + { + if (pooling || shared) + { + await CSharpDbClient.ClearPoolAsync(host.CompatibilityEndpoint.AbsoluteUri); + await CSharpDbClient.ClearPoolAsync(host.NativeEndpoint.AbsoluteUri); + } + } + } + + private static async Task SettleAsync(GrpcSocketDaemonFixture host, + string scenario, CSharpDbGrpcMode mode, int seconds, CancellationToken ct) + { + string connectionString = ConnectionString(host, scenario, mode); + ICSharpDbClient? client = null; + CSharpDbConnection? connection = null; + CSharpDbCommand? command = null; + try + { + if (scenario == "sdk-reused") client = host.CreateClient(mode); + else if (scenario == "ado-reused") + { + connection = new CSharpDbConnection(connectionString); + await connection.OpenAsync(ct); + command = connection.CreateCommand(); + command.CommandText = Query; + } + else if (scenario == "sdk-pooled-read-dispose") + { + await using var first = await CSharpDbClient.RentAsync(ClientOptions(host, mode), 1, ct); + } + else if (scenario == "ado-pooled-open-read-close") + { + // The first physical validation is outside settling and timing; + // all following logical opens must perform zero GetInfo calls. + await using var first = new CSharpDbConnection(connectionString); + await first.OpenAsync(ct); + } + var before = host.Snapshot(); + long start = Stopwatch.GetTimestamp(); + int completed = 0; + do + { + if (client is not null) + { + ValidateSdkResult(await client.ExecuteSqlAsync(Query, ct)); + } + else if (command is not null) ValidateScalar(await command.ExecuteScalarAsync(ct)); + else if (scenario.StartsWith("sdk-", StringComparison.Ordinal)) + { + await using var opened = scenario == "sdk-pooled-read-dispose" + ? await CSharpDbClient.RentAsync(ClientOptions(host, mode), 1, ct) : host.CreateClient(mode); + ValidateSdkResult(await opened.ExecuteSqlAsync(Query, ct)); + } + else + { + await using var opened = new CSharpDbConnection(connectionString); + await opened.OpenAsync(ct); + using var query = opened.CreateCommand(); + query.CommandText = Query; + ValidateScalar(await query.ExecuteScalarAsync(ct)); + } + completed++; + } while (seconds == 0 ? completed < 100 : Stopwatch.GetElapsedTime(start).TotalSeconds < seconds); + double elapsed = Stopwatch.GetElapsedTime(start).TotalSeconds; + ValidateRequests(before, host.Snapshot(), mode, scenario, completed); + Console.WriteLine($"Settled {scenario} {mode}: {completed} operations in {elapsed:N1}s"); + return new SettlingResult(scenario, mode.ToString(), completed, elapsed); + } + finally + { + command?.Dispose(); + if (connection is not null) await connection.DisposeAsync(); + if (client is not null) await client.DisposeAsync(); + } + } + + private static async Task MeasureAsync(GrpcSocketDaemonFixture host, string scenario, + CSharpDbGrpcMode mode, int round, int count, string output, CancellationToken ct) + { + ICSharpDbClient? client = null; + CSharpDbConnection? connection = null; + CSharpDbCommand? command = null; + BlockResult? blockResult = null; + string connectionString = ConnectionString(host, scenario, mode); + try + { + if (scenario == "sdk-reused") + client = host.CreateClient(mode); + else if (scenario == "ado-reused") + { + connection = new CSharpDbConnection(connectionString); + await connection.OpenAsync(ct); + command = connection.CreateCommand(); + command.CommandText = Query; + } + async Task ExecuteAsync() + { + if (client is not null) + { + ValidateSdkResult(await client.ExecuteSqlAsync(Query, ct)); + } + else if (command is not null) + ValidateScalar(await command.ExecuteScalarAsync(ct)); + else if (scenario.StartsWith("sdk-", StringComparison.Ordinal)) + { + await using var opened = scenario == "sdk-pooled-read-dispose" + ? await CSharpDbClient.RentAsync(ClientOptions(host, mode), 1, ct) : host.CreateClient(mode); + ValidateSdkResult(await opened.ExecuteSqlAsync(Query, ct)); + } + else + { + await using var opened = new CSharpDbConnection(connectionString); + await opened.OpenAsync(ct); + using var query = opened.CreateCommand(); + query.CommandText = Query; + ValidateScalar(await query.ExecuteScalarAsync(ct)); + } + } + int warmup = scenario.EndsWith("reused", StringComparison.Ordinal) ? 100 : 25; + for (int i = 0; i < warmup; i++) + await ExecuteAsync(); + + var before = host.Snapshot(); + var ticks = new long[count]; + using var process = Process.GetCurrentProcess(); + var cpuBefore = process.TotalProcessorTime; + long allocatedBefore = GC.GetTotalAllocatedBytes(precise: true); + long blockStart = Stopwatch.GetTimestamp(); + int completed = 0; + try + { + for (int i = 0; i < count; i++) + { + long start = Stopwatch.GetTimestamp(); + await ExecuteAsync(); + ticks[i] = Stopwatch.GetTimestamp() - start; + completed++; + } + } + catch (Exception error) + { + WriteJson(output, $"{scenario}-{mode}-round-{round}.failure.json", new + { + Scenario = scenario, Mode = mode.ToString(), Round = round, + FailedSample = completed + 1, Error = error.ToString(), + }); + throw; + } + finally + { + // Preserve successful samples even if the next operation fails. + // These files are written after timing, not in the request loop. + long end = Stopwatch.GetTimestamp(); + long allocatedAfter = GC.GetTotalAllocatedBytes(precise: true); + var cpuAfter = process.TotalProcessorTime; + string stem = $"{scenario}-{mode}-round-{round}"; + await File.WriteAllLinesAsync(Path.Combine(output, stem + ".csv"), + new[] { "Sample,ElapsedTicks,LatencyUs" }.Concat(ticks.Take(completed).Select((tick, index) => + $"{index + 1},{tick},{(tick * 1_000_000.0 / Stopwatch.Frequency).ToString("R", CultureInfo.InvariantCulture)}"))); + WriteJson(output, stem + ".metrics.json", new + { + Completed = completed, Requested = count, ElapsedTicks = end - blockStart, + AllocatedBytes = allocatedAfter - allocatedBefore, CpuMs = (cpuAfter - cpuBefore).TotalMilliseconds, + }); + if (completed == count) + { + ValidateRequests(before, host.Snapshot(), mode, scenario, count); + double elapsedSeconds = (end - blockStart) / (double)Stopwatch.Frequency; + Array.Sort(ticks); + blockResult = new BlockResult(scenario, mode.ToString(), round, count, + count / elapsedSeconds, Percentile(ticks, 0.50), Percentile(ticks, 0.95), Percentile(ticks, 0.99), + allocatedAfter - allocatedBefore, (cpuAfter - cpuBefore).TotalMilliseconds); + } + } + return blockResult ?? throw new InvalidOperationException("The measurement block did not finish."); + } + finally + { + command?.Dispose(); + if (connection is not null) await connection.DisposeAsync(); + if (client is not null) await client.DisposeAsync(); + } + } + + private static void ValidateRequests(IReadOnlyDictionary before, + IReadOnlyDictionary after, CSharpDbGrpcMode mode, string scenario, int count) + { + string expectedProtocol = mode == CSharpDbGrpcMode.Native ? "HTTP/2" : "HTTP/1.1"; + string expectedContentType = mode == CSharpDbGrpcMode.Native ? "application/grpc" : "application/grpc-web"; + long queries = 0, opens = 0; + foreach (var pair in after) + { + before.TryGetValue(pair.Key, out long previous); + long delta = pair.Value - previous; + if (delta == 0) continue; + if (pair.Key.Protocol != expectedProtocol || !pair.Key.ContentType.StartsWith(expectedContentType, StringComparison.Ordinal) + || (mode == CSharpDbGrpcMode.Native && pair.Key.ContentType.Contains("grpc-web", StringComparison.Ordinal))) + throw new InvalidOperationException($"Unexpected wire protocol: {pair.Key}"); + if (pair.Key.Method.EndsWith("/ExecuteSql", StringComparison.Ordinal)) queries += delta; + else if (pair.Key.Method.EndsWith("/GetInfo", StringComparison.Ordinal)) opens += delta; + else throw new InvalidOperationException($"Unexpected request during measurement: {pair.Key.Method}"); + } + if (queries != count || opens != (scenario == "ado-open-read-close" ? count : 0)) + throw new InvalidOperationException($"Unexpected request counts: {queries} queries and {opens} opens."); + } + + private static double Percentile(long[] sorted, double percentile) + => sorted[Math.Clamp((int)Math.Ceiling(sorted.Length * percentile) - 1, 0, sorted.Length - 1)] * 1_000_000.0 / Stopwatch.Frequency; + private static double Median(IEnumerable values) + { + double[] sorted = values.Order().ToArray(); + return sorted.Length % 2 == 1 ? sorted[sorted.Length / 2] : (sorted[sorted.Length / 2 - 1] + sorted[sorted.Length / 2]) / 2; + } + private static string ConnectionString(GrpcSocketDaemonFixture host, string scenario, CSharpDbGrpcMode mode) + => $"Transport=Grpc;Grpc Mode={mode};Endpoint={host.EndpointFor(mode)}" + + (scenario == "ado-pooled-open-read-close" ? ";Pooling=true;Max Pool Size=1" : ""); + private static CSharpDbClientOptions ClientOptions(GrpcSocketDaemonFixture host, CSharpDbGrpcMode mode) => new() + { + Transport = CSharpDbTransport.Grpc, GrpcMode = mode, Endpoint = host.EndpointFor(mode).AbsoluteUri, + }; + private static bool IsFresh(string scenario) => scenario is "sdk-create-read-dispose" or "ado-open-read-close"; + private static void ValidateSdkResult(CSharpDB.Client.Models.SqlExecutionResult result) + { + Check(result); + if (result.Rows is not { Count: 1 } || result.Rows[0].Length != 1 || result.Rows[0][0] is not long value || value != 42) + throw new InvalidOperationException("SDK query returned the wrong result."); + } + private static void Check(CSharpDB.Client.Models.SqlExecutionResult result) + { + if (result.Error is not null) throw new InvalidOperationException(result.Error); + } + private static void ValidateScalar(object? value) + { + if (value is null || Convert.ToInt64(value, CultureInfo.InvariantCulture) != 42) + throw new InvalidOperationException("ADO.NET query returned the wrong result."); + } + private static void WriteJson(string output, string name, object value) + => File.WriteAllText(Path.Combine(output, name), JsonSerializer.Serialize(value, JsonOptions)); + private static BinaryHash[] HashBinaries() + => Directory.EnumerateFiles(AppContext.BaseDirectory).Where(path => path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) + || path.EndsWith(".runtimeconfig.json", StringComparison.OrdinalIgnoreCase) + || path.EndsWith(".deps.json", StringComparison.OrdinalIgnoreCase)).Order(StringComparer.Ordinal).Select(path => + new BinaryHash(Path.GetFileName(path), Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))))).ToArray(); + private static BinaryHash[] HashSources() + { + string root = Git("rev-parse", "--show-toplevel"); + return Git("ls-files", "-z", "--cached", "--others", "--exclude-standard", "src", "tests/Shared", + "tests/CSharpDB.NetworkBenchmarks", "Directory.Build.props", "Directory.Build.targets", "global.json") + .Split('\0', StringSplitOptions.RemoveEmptyEntries).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal) + .Where(path => File.Exists(Path.Combine(root, path))) + .Select(path => new BinaryHash(path, Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(Path.Combine(root, path)))))).ToArray(); + } + private static string Git(params string[] arguments) + { + var info = new ProcessStartInfo("git") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; + foreach (var argument in arguments) info.ArgumentList.Add(argument); + using var process = Process.Start(info)!; + string result = process.StandardOutput.ReadToEnd(); + string error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + if (process.ExitCode != 0) throw new InvalidOperationException(error); + return result.TrimEnd(); + } + private static Options Parse(string[] args) + { + string? output = null; + string experiment = "native"; + int iterations = 2000, opens = 300, rounds = 4, settleSeconds = 10; + for (int i = 0; i < args.Length; i += 2) + { + if (i + 1 >= args.Length) throw new ArgumentException($"Missing value for {args[i]}."); + switch (args[i]) + { + case "--output": output = args[i + 1]; break; + case "--experiment": experiment = args[i + 1]; break; + case "--iterations": iterations = int.Parse(args[i + 1], CultureInfo.InvariantCulture); break; + case "--open-iterations": opens = int.Parse(args[i + 1], CultureInfo.InvariantCulture); break; + case "--rounds": rounds = int.Parse(args[i + 1], CultureInfo.InvariantCulture); break; + case "--settle-seconds": settleSeconds = int.Parse(args[i + 1], CultureInfo.InvariantCulture); break; + default: throw new ArgumentException($"Unknown option {args[i]}."); + } + } + if (experiment is not ("native" or "pooling" or "shared")) throw new ArgumentException("Experiment must be native, pooling, or shared."); + if (experiment == "pooling") + { + if (!args.Contains("--rounds")) rounds = 6; + if (!args.Contains("--iterations")) iterations = 1000; + if (rounds != 6) throw new ArgumentException("The pooling experiment requires six balanced rounds."); + } + if (experiment == "shared") + { + if (!args.Contains("--rounds")) rounds = 12; + if (!args.Contains("--iterations")) iterations = 1000; + if (!args.Contains("--open-iterations")) opens = 100; + if (rounds != 12) throw new ArgumentException("The shared experiment requires twelve balanced rounds."); + if (opens > 100) throw new ArgumentException("Shared fresh-client blocks are capped at 100 operations to stay within the socket budget."); + } + if (string.IsNullOrWhiteSpace(output) || iterations is < 1 or > 100000 || opens is < 1 or > 10000 || rounds < 2 || rounds > (experiment == "shared" ? 12 : 10) || rounds % 2 != 0 || settleSeconds is < 1 or > 15) + throw new ArgumentException("Supply a new output directory, 1..100000 iterations, 1..10000 open iterations, an even round count from 2..10, and 1..15 settling seconds."); + return new Options(output, iterations, opens, rounds, settleSeconds, experiment); + } + private sealed record Options(string Output, int Iterations, int OpenIterations, int Rounds, int SettleSeconds, string Experiment); + private sealed record SettlingResult(string Scenario, string Mode, int Completed, double ElapsedSeconds); + private sealed record BenchmarkCase(string Scenario, CSharpDbGrpcMode Mode); + private sealed record BinaryHash(string Name, string Sha256); + private sealed record BlockResult(string Scenario, string Mode, int Round, int Count, double OperationsPerSecond, + double P50Us, double P95Us, double P99Us, long AllocatedBytes, double CpuMs); +} diff --git a/tests/CSharpDB.NetworkBenchmarks/README.md b/tests/CSharpDB.NetworkBenchmarks/README.md new file mode 100644 index 00000000..6508815a --- /dev/null +++ b/tests/CSharpDB.NetworkBenchmarks/README.md @@ -0,0 +1,311 @@ +# Network socket experiments + +The gRPC micro-optimization phase is closed. See the +[closeout and evidence index](CLOSEOUT.md) for the retained client/session +foundation, rejected candidates, validation boundary, and external archive +receipts. Persistent streaming remains experimental and opt-in; unary calls +remain the default. + +This directory contains distinct historical workloads. The initial pilot below +does not describe the topology or pooling behavior of every later mode. + +| Mode | Workload documentation | +| --- | --- | +| `native` (initial pilot) | [Original socket pilot](#original-socket-pilot) | +| `pooling` | [Remote gRPC pooling pilot](#remote-grpc-pooling-pilot) | +| `shared` | [Shared SDK and ADO.NET comparison](#shared-sdk-and-adonet-comparison) | +| `session` | [Persistent command session experiment](#persistent-command-session-experiment) | +| `session-isolated` | [Separate client and daemon processes](#separate-client-and-daemon-processes) | +| `session-c8` | [Eight concurrent pooled sessions](#eight-concurrent-pooled-sessions) | +| `pooled-lifecycle-c8` | [Complete pooled request lifecycle](#complete-pooled-request-lifecycle) | +| `pooled-allocation-profile` | [Warm pooled allocation profile](#warm-pooled-allocation-profile) | + +The final paired qualifications additionally used an external frozen +`pooled-unary-guard` adapter. That mode is not a command implemented by this +checkout; reproduction requires the archived manifests and helper described in +the closeout. + +## Original socket pilot + +This bounded pilot compares the existing plain-HTTP gRPC-Web/HTTP/1.1 client +with opt-in native gRPC/HTTP/2. Both listeners run inside the actual daemon, +share one warm database, and execute identical unary SQL requests. The +server records the observed HTTP version, content type, method, and count; +each measured block must match its expected wire protocol and request counts. + +The workloads are one-row point reads through a reused SDK client, a reused +ADO.NET connection, and ADO.NET open/read/close cycles. The last workload +includes the current `GetInfo` validation request on every opening. It does +not implement or simulate connection pooling, prepared handles, batching, +or a custom TCP protocol. + +Build once, then run the frozen executable from the repository root: + +```powershell +dotnet build tests/CSharpDB.NetworkBenchmarks/CSharpDB.NetworkBenchmarks.csproj -c Release +dotnet tests/CSharpDB.NetworkBenchmarks/bin/Release/net10.0/CSharpDB.NetworkBenchmarks.dll --output C:/benchmarks/network-pilot-new --iterations 2000 --open-iterations 300 --rounds 4 --settle-seconds 10 +``` + +Choose a fresh output directory outside the repository for every run. The +harness refuses an existing directory. Protocol version 2 settles each mode +for ten seconds before measuring each workload, then warms each individual +block with 100 operations, or 25 for open/read/close. Version 1 used only the +per-block warmup and showed substantial throughput drift; its evidence must +remain separate from version 2. +Four rounds alternate Auto/Native then Native/Auto, with identical counts and +no retries. The entire run has a three-minute cancellation deadline. + +Output includes the protocol frozen before measurement, source and binary +hashes, the working diff and new source files, every latency sample, per-block +CPU/allocation totals, server request counts, and medians across blocks. Raw +samples are written outside the timed request loop. A failed block retains +its completed samples; failures are recorded and the run stops. Timing results +have no speed-based promotion gate in this initial pilot, and all blocks must +be reported. A separate stability screen requires maximum/minimum block +throughput to differ by at most 25% for every scenario/mode. A failed screen +leaves the result descriptive and does not trigger a retry. + +These are cleartext loopback measurements with one outstanding operation and +client and server in the same process using server GC. Process allocation and +CPU metrics include both sides. They are not separate-machine, TLS, maximum +throughput, or release qualification measurements. Keep generated evidence +outside the repository and compare all rounds before drawing conclusions. + +## Remote gRPC pooling pilot + +Protocol version 3 compares three arms on the existing Auto +gRPC-Web/HTTP/1.1 path: unpooled open/read/close (U), pooled open/read/close (P), +and an already-open connection (R). The pooled arm uses `Max Pool Size=1` and +performs its first physical `GetInfo` check before settling. Subsequent pooled +opens must send zero `GetInfo` RPCs; unpooled opens must send one each. All +arms use the same one-row query, host, and fixed iteration count. + +```powershell +dotnet tests/CSharpDB.NetworkBenchmarks/bin/Release/net10.0/CSharpDB.NetworkBenchmarks.dll --experiment pooling --output C:/benchmarks/pooling-pilot-new --iterations 1000 --rounds 6 --settle-seconds 10 +``` + +Each arm settles for ten seconds before the six rounds. Round orders are +UPR, PRU, RUP, RPU, PUR, URP, balancing every arm's position. Each block warms +with 25 open/read/close operations or 100 already-open reads, followed by +1,000 measured operations. The pooling pilot uses `--iterations` for all +arms; `--open-iterations` applies only to the native comparison. + +The predeclared pilot gate requires all three arms to pass the 25% stability +screen, at least 25% higher median throughput for pooling than unpooled, +and pooled median block p99 no worse than unpooled. Failed gates cause no +retry. Preserve and report every block, including failures. Pooling is +opt-in; this pilot does not qualify concurrency, TLS, separate-host latency, +or remote prepared statements. The same evidence and three-minute deadline +rules apply. + +## Shared SDK and ADO.NET comparison + +Protocol version 5 measures both APIs in one process, with six lifetimes: +SDK reused, ADO reused, SDK create/read/dispose, ADO open/read/close, SDK +rent/read/dispose, and ADO pooled open/read/close. Each runs over both Auto +gRPC-Web/HTTP/1.1 and Native gRPC/HTTP/2, giving 12 cases. Pooled cases use +the same SDK-owned pool with `Max Pool Size=1` for each endpoint and mode. + +```powershell +dotnet tests/CSharpDB.NetworkBenchmarks/bin/Release/net10.0/CSharpDB.NetworkBenchmarks.dll --experiment shared --output C:/benchmarks/shared-pool-new --iterations 1000 --open-iterations 100 --rounds 12 --settle-seconds 10 +``` + +Reused/pooled cases settle for ten seconds; fresh-client cases settle with +100 operations. Twelve measured rounds follow a Williams +design: base case indices `0,1,11,2,10,3,9,4,8,5,7,6`, shifted by round modulo +12. Each case occupies every position exactly once; immediate predecessors +are balanced. Reused/pooled blocks measure 1,000 identical one-row reads; +fresh blocks measure 100. Each block follows 100 reused-client warmups or 25 +client-lifecycle warmups, for 144 blocks and 100,800 samples. The whole run +has a five-minute cancellation deadline. Fresh cases therefore have fewer +latency samples; median block p99 is a pilot estimate. + +Version 4 used unlimited ten-second settling of fresh clients and exhausted +Windows socket addresses (10048) before completing any measured block. +Keep its failed evidence. Version 5 caps fresh settling and measured counts, +using approximately 6,400 fresh connections plus reference-client setup, +below the observed Windows dynamic range of 16,384 ports. Do not change OS +network settings or retry failed blocks. + +SDK `Create` makes no validation call; ADO fresh opens make one `GetInfo` +per operation. Shared rentals perform one physical validation per endpoint +and mode before settling, then zero validation RPCs during measured blocks. +All protocol and request counts are checked against the actual daemon. + +The predeclared gate requires all 12 cases to pass the 25% stability screen. +For both APIs and both transports, pooling must improve median throughput by +at least 25% over fresh clients and have median block p99 no worse than fresh +clients. Failures cause no retry; retain and report all evidence. Reused +clients remain reference cases rather than pooling speedup claims. Earlier +pilot versions stay separate; do not compare their absolute numbers as a +causal improvement. + +## Persistent command session experiment + +```powershell +dotnet tests/CSharpDB.NetworkBenchmarks/bin/Release/net10.0/CSharpDB.NetworkBenchmarks.dll --experiment session --output C:/benchmarks/session-new +``` + +This fixed C1 experiment compares pooled, reused SDK and ADO.NET calls over +gRPC-Web unary, native HTTP/2 unary, and a persistent native gRPC stream. +All six arms share the actual daemon and warm one-row query; SDK and ADO.NET +use the same pool per wire mode with capacity one. Rental/open and warmup +occur outside timing. The stream allows one outstanding command. + +Each arm settles for three seconds. Six Williams rounds use base indices +`0,1,5,2,4,3`, shifted by round modulo six, so each arm occupies each position +once. Every block has 256 warmups and 8,192 measured operations: 36 blocks +and 294,912 raw samples. Counts, order, gates, and the five-minute deadline +are fixed. There are no retries or exclusions. + +The harness validates every scalar, the HTTP request shape, and decoded +stream command/response counts. Timed unary blocks must have exactly 8,192 +SQL RPCs and no validation RPCs; stream blocks must have exactly 8,192 SQL +frames and responses and zero new HTTP RPCs. The extra test interceptor +counts unary calls and stream messages. Source snapshots, source/binary +hashes, raw QPC ticks, CPU/allocations, wire counts, and failures are saved +outside the repository. Source/binary changes invalidate the run. + +For each API, both baselines and the stream must pass the 25% max/min +throughput stability screen. Streaming must beat each baseline's median +throughput by at least 5%, win at least five of six paired rounds against +each, and have median block p99 no worse than each. Failed gates leave +descriptive results. This qualifies only one outstanding cleartext loopback +read at a time; TLS, remote hosts, concurrent sessions, writes, batching, +and prepared handles require separate experiments. + +The session protocol manifest version 1 stopped during ADO.NET settling, +before any timed block, because its scalar check incorrectly required +`Int64` for an `INTEGER` value exposed by ADO.NET as `Int32`. Version 2 accepts +either integer representation of 42. Counts, order, and gates are unchanged; +retain version 1 evidence alongside the corrected harness run. + +### Runtime diagnosis + +Use `--experiment session-diagnostic` with a fresh output directory to add +client/server stage timings and a dedicated-thread sampler of ThreadPool +dispatch delay. The `CSharpDB-Network-Diagnostic` EventSource emits QPC clock +and block markers for joining an external EventPipe GC/threading trace. +This mode adds overhead and never qualifies transport speed. It keeps the +same fixed SQL, arm order, operation counts, wire checks, and deadline. +`*.stages.csv` records every command's client start/end, server arrival, +backend execution, and response-write boundaries; `threadpool-dispatch.csv` +retains queued/completed timestamps and runtime counters. A stream write may +finish after its client has received the response, so its end is not treated +as an additive component of client latency. + +### Separate client and daemon processes + +```powershell +dotnet tests/CSharpDB.NetworkBenchmarks/bin/Release/net10.0/CSharpDB.NetworkBenchmarks.dll --experiment session-isolated --output C:/benchmarks/session-isolated-new +``` + +This is a separate deployment experiment: the parent runs SDK/ADO.NET and +one owned child hosts the actual daemon through the same socket fixture. +The two processes use separate heaps and ThreadPools. It installs no stage +probes or profiler. All six arms use the same child/database and the existing +shared client pool. Counts, order, warmups, settling, gates, and deadline +match the original C1 experiment. This remains cleartext loopback on one +machine; it does not qualify TLS, separate hosts, concurrent sessions, or +writes, and does not replace a failed same-process result. + +The child sends endpoint/runtime/process identity metadata and snapshots of +HTTP and decoded command counts over redirected control pipes. Control IPC +is outside timing; it carries no SQL. CPU and allocation totals include both +processes and bounded snapshot overhead. The parent requests graceful stop +and waits for child exit; shutdown failure kills only that owned child tree. +Client latency samples survive a failed counter snapshot. `host-info.json` +records the separate process IDs and server runtime. + +### Eight concurrent pooled sessions + +```powershell +dotnet tests/CSharpDB.NetworkBenchmarks/bin/Release/net10.0/CSharpDB.NetworkBenchmarks.dll --experiment session-c8 --output C:/benchmarks/session-c8-new +``` + +This fixed C8 experiment runs eight workers against the same separate-process +daemon. Each worker holds its own SDK rental or ADO.NET connection and command; +each pool family has capacity eight. SDK and ADO.NET reuse the same physical +owners for matching wire options. All six arms remain gRPC-Web unary, native +unary, and native streaming through both APIs. Ordinary SQL queues on the +daemon backend's existing client lock, so this measures transport concurrency +and server queueing together. + +Every arm settles for three seconds. Each block warms 256 reads per worker, +then all workers signal readiness before one asynchronous start barrier. +Each worker measures 4,096 reads: 32,768 per block, 36 balanced blocks, and +1,179,648 total. The six-round Williams ordering and five-minute deadline are +fixed. Each scalar must equal 42. There are no retries or excluded rounds. +On a worker failure, siblings are canceled and all workers drain before +snapshot/disposal; each worker's completed samples and error are retained. + +Raw CSV records worker/sample identity and absolute QPC start/end timestamps. +Aggregate throughput uses elapsed wall time from barrier release through all +workers finishing. Latency quantiles pool all samples; the maximum of the +eight worker p99 values is reported separately. Readiness/completion times, +CPU/allocations, HTTP and decoded command counts, and source/binary hashes +are retained. Every timed block requires exactly 32,768 SQL commands and +responses with no validation/handshake RPCs. Before and after the run, the +daemon must show exactly eight physical owners per wire-mode pool, with no +additional owners when ADO.NET follows SDK. + +For each API, all three arms must pass the 25% max/min throughput stability +screen. Streaming must beat each baseline's median throughput by at least +5%, win at least five of six paired rounds, and have both median pooled p99 +and median worst-worker p99 no worse than each baseline. Both comparisons +must pass to qualify an API. Failed gates remain descriptive. This does not +qualify 32 workers, TLS, separate hosts, writes, or transaction throughput. + +Use `--experiment session-c8-timing` for the same fixed C8 workload and gates +with timing and CPU accounting only. This mode never requests or reports +process allocation counters. It records finite, nonnegative, monotonic CPU +snapshots and preserves raw evidence if validation fails. Historical modes +remain available for reproduction; their allocation fields require separate +validation and must not be treated as exact memory savings. + +### Complete pooled request lifecycle + +```powershell +dotnet tests/CSharpDB.NetworkBenchmarks/bin/Release/net10.0/CSharpDB.NetworkBenchmarks.dll --experiment pooled-lifecycle-c8 --output C:/benchmarks/pooled-lifecycle-c8-new +``` + +This diagnostic compares a held rental with a complete short-lived pooled +request through both the SDK and ADO.NET. It explicitly selects native HTTP/2 +streaming and a shared pool of eight physical sessions. Each lifecycle sample +includes renting or opening, one validated scalar read, and awaited disposal +or close; held rentals and their commands are created outside measurement. +SDK options and the ADO connection string are reused between requests. + +Eight workers each complete 4,096 requests per block. Four arms run in eight +balanced rounds: 32 blocks and 1,048,576 measured reads. Each block warms 256 +requests per worker, after an initial three-second settling period per arm. +Prewarming holds all eight sessions concurrently and checks that switching +from SDK to ADO.NET creates no additional physical session. Timed blocks must +contain only the expected SQL frames and responses, with no new HTTP calls, +validation, or handshake frames. + +Client and daemon run in separate processes at Normal priority. The mode +collects raw per-worker QPC samples and timing/CPU snapshots, without allocation +counters, a profiler, startup hooks, or priority changes. It preserves partial +evidence and stops on failure, with no retries or excluded blocks. The fixed +throughput spread screen is 25% for each arm. Ratios of lifecycle to held +throughput describe this workload; they do not qualify a product optimization +or a comparison with historical benchmark results. The workload is warm +cleartext loopback and does not establish TLS, remote-host, or write performance. + +### Warm pooled allocation profile + +`--experiment pooled-allocation-profile --output ` isolates +warm checkout and return without executing SQL or creating commands. Eight +workers run 32,768 cycles each in four fixed blocks: SDK, ADO.NET, ADO.NET, +SDK. Both APIs share eight prewarmed native HTTP/2 streaming owners. SDK +options and the exact ADO.NET connection-string instance are reused. Each +worker waits behind an asynchronous release barrier before its loop. + +Boundary counters report process-wide managed allocation, including client +harness and background work. Optional client EventPipe allocation ticks +provide sampled stack attribution; their weights are not exact per-type +bytes or object counts. The separate daemon must remain at Normal priority, +and every measured block must create zero RPC frames or HTTP calls. This +mode permits launch-attached EventPipe profiling and always records +`QualificationEligible=false`. It cannot qualify throughput improvements. diff --git a/tests/CSharpDB.NetworkBenchmarks/SessionDiagnosticProbe.cs b/tests/CSharpDB.NetworkBenchmarks/SessionDiagnosticProbe.cs new file mode 100644 index 00000000..ffc7be86 --- /dev/null +++ b/tests/CSharpDB.NetworkBenchmarks/SessionDiagnosticProbe.cs @@ -0,0 +1,178 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Diagnostics.Tracing; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Text.Json; +using CSharpDB.Client; +using CSharpDB.Client.Grpc; +using CSharpDB.Client.Models; +using Grpc.Core; +using Grpc.Core.Interceptors; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CSharpDB.NetworkBenchmarks; + +/// Benchmark-only C1 timing. Adds overhead and cannot qualify transport speed. +internal sealed class SessionDiagnosticProbe : IDisposable +{ + private readonly ConcurrentQueue _dispatch = new(); + private readonly ManualResetEventSlim _stop = new(); + private readonly Thread _sampler; + private Sample[] _samples = []; + private Sample? _current; + private string _api = "", _wire = ""; + private int _round; + + public SessionDiagnosticProbe() + { + DiagnosticEvents.Log.Clock(Stopwatch.GetTimestamp(), Stopwatch.Frequency); + _sampler = new Thread(SampleDispatch) { IsBackground = true, Name = "Network diagnostic dispatch sampler" }; + _sampler.Start(); + } + + public void Configure(IServiceCollection services) + { + services.AddSingleton(new StageInterceptor(this)); + services.AddGrpc(options => options.Interceptors.Add()); + var original = services.Last(x => x.ServiceType == typeof(ICSharpDbClient)); + services.Replace(ServiceDescriptor.Singleton(provider => + { + var proxy = DispatchProxy.Create(); + var state = (BackendProxy)(object)proxy; + state.Inner = (ICSharpDbClient)original.ImplementationFactory!(provider); + state.Probe = this; + return proxy; + })); + } + + public void BeginBlock(string api, string wire, int round, int count) + { + _api = api; _wire = wire; _round = round; + _samples = Enumerable.Range(0, count).Select(_ => new Sample()).ToArray(); + DiagnosticEvents.Log.Block(api, wire, round, 1, Stopwatch.GetTimestamp()); + } + public void BeginSample(int index, long timestamp) + { + var sample = _samples[index]; + sample.ClientStart = timestamp; + Volatile.Write(ref _current, sample); + } + public void EndSample(int index, long timestamp) => _samples[index].ClientEnd = timestamp; + public void EndBlock() + { + Volatile.Write(ref _current, null); + DiagnosticEvents.Log.Block(_api, _wire, _round, 0, Stopwatch.GetTimestamp()); + } + public async Task SaveBlockAsync(string output, string stem, int completed, CancellationToken ct) + { + // A stream WriteAsync can finish just after the client sees the response. + var timer = Stopwatch.StartNew(); + while (_wire == "Stream" && _samples.Take(completed).Any(x => Volatile.Read(ref x.ServerWriteEnd) == 0) && timer.Elapsed < TimeSpan.FromSeconds(5)) + await Task.Delay(1, ct); + File.WriteAllLines(Path.Combine(output, stem + ".stages.csv"), new[] + { "Sample,ClientStart,ServerReceived,EngineStart,EngineEnd,ServerResponseStart,ServerWriteEnd,ClientEnd" } + .Concat(_samples.Take(completed).Select((x, i) => $"{i + 1},{x.ClientStart},{x.ServerReceived},{x.EngineStart},{x.EngineEnd},{x.ServerResponseStart},{x.ServerWriteEnd},{x.ClientEnd}"))); + } + public void Save(string output) + { + _stop.Set(); + _sampler.Join(TimeSpan.FromSeconds(2)); + File.WriteAllLines(Path.Combine(output, "threadpool-dispatch.csv"), new[] + { "QueuedQpc,RanQpc,PendingWork,ThreadCount,CompletedWork,Gen0,Gen1,Gen2" } + .Concat(_dispatch.Select(x => $"{x.Queued},{Volatile.Read(ref x.Ran)},{x.Pending},{x.Threads},{x.Completed},{x.Gen0},{x.Gen1},{x.Gen2}"))); + ThreadPool.GetMinThreads(out int minimum, out int ioMinimum); + File.WriteAllText(Path.Combine(output, "diagnostic-runtime.json"), JsonSerializer.Serialize(new + { Stopwatch.Frequency, ThreadPoolMinimum = minimum, IoMinimum = ioMinimum, System.Runtime.GCSettings.IsServerGC, ThreadPool.ThreadCount, + DispatchSamples = _dispatch.Count, DiagnosticOnly = true })); + } + private void SampleDispatch() + { + do + { + var sample = new Dispatch { Queued = Stopwatch.GetTimestamp(), Pending = ThreadPool.PendingWorkItemCount, + Threads = ThreadPool.ThreadCount, Completed = ThreadPool.CompletedWorkItemCount, + Gen0 = GC.CollectionCount(0), Gen1 = GC.CollectionCount(1), Gen2 = GC.CollectionCount(2) }; + _dispatch.Enqueue(sample); + ThreadPool.UnsafeQueueUserWorkItem(static state => Volatile.Write(ref state.Ran, Stopwatch.GetTimestamp()), sample, preferLocal: false); + } while (!_stop.Wait(100)); + } + public void Dispose() { _stop.Set(); _sampler.Join(TimeSpan.FromSeconds(2)); _stop.Dispose(); } + + private sealed class Sample + { + public long ClientStart, ServerReceived, EngineStart, EngineEnd, ServerResponseStart, ServerWriteEnd, ClientEnd; + } + private sealed class Dispatch + { + public long Queued, Ran, Pending, Completed; + public int Threads, Gen0, Gen1, Gen2; + } + + public class BackendProxy : DispatchProxy + { + public ICSharpDbClient Inner = null!; + internal SessionDiagnosticProbe Probe = null!; + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + if (targetMethod!.Name == nameof(ICSharpDbClient.ExecuteSqlAsync) && Volatile.Read(ref Probe._current) is { } sample) + return ObserveSqlAsync((string)args![0]!, (CancellationToken)args[1]!, sample); + try { return targetMethod.Invoke(Inner, args); } + catch (TargetInvocationException error) when (error.InnerException is not null) + { ExceptionDispatchInfo.Capture(error.InnerException).Throw(); throw; } + } + private async Task ObserveSqlAsync(string sql, CancellationToken ct, Sample sample) + { + sample.EngineStart = Stopwatch.GetTimestamp(); + try { return await Inner.ExecuteSqlAsync(sql, ct).ConfigureAwait(false); } + finally { sample.EngineEnd = Stopwatch.GetTimestamp(); } + } + } + public sealed class StageInterceptor(SessionDiagnosticProbe probe) : Interceptor + { + public override async Task UnaryServerHandler(TRequest request, ServerCallContext context, + UnaryServerMethod continuation) + { + Sample? sample = request is SqlRequest ? Volatile.Read(ref probe._current) : null; + if (sample is not null) sample.ServerReceived = Stopwatch.GetTimestamp(); + try { return await continuation(request, context).ConfigureAwait(false); } + finally { if (sample is not null) sample.ServerResponseStart = Stopwatch.GetTimestamp(); } + } + public override Task DuplexStreamingServerHandler(IAsyncStreamReader reader, + IServerStreamWriter writer, ServerCallContext context, DuplexStreamingServerMethod continuation) + => continuation(new Reader(reader, probe), new Writer(writer, probe), context); + private sealed class Reader(IAsyncStreamReader inner, SessionDiagnosticProbe owner) : IAsyncStreamReader + { + public T Current => inner.Current; + public async Task MoveNext(CancellationToken ct) + { + if (!await inner.MoveNext(ct).ConfigureAwait(false)) return false; + if (Current is CommandSessionRequest { ExecuteSql: not null } && Volatile.Read(ref owner._current) is { } sample) + sample.ServerReceived = Stopwatch.GetTimestamp(); + return true; + } + } + private sealed class Writer(IServerStreamWriter inner, SessionDiagnosticProbe owner) : IServerStreamWriter + { + public WriteOptions? WriteOptions { get => inner.WriteOptions; set => inner.WriteOptions = value; } + public async Task WriteAsync(T message) + { + Sample? sample = message is CommandSessionResponse { Result: not null } ? Volatile.Read(ref owner._current) : null; + if (sample is not null) sample.ServerResponseStart = Stopwatch.GetTimestamp(); + try { await inner.WriteAsync(message).ConfigureAwait(false); } + finally { if (sample is not null) Volatile.Write(ref sample.ServerWriteEnd, Stopwatch.GetTimestamp()); } + } + } + } +} + +[EventSource(Name = "CSharpDB-Network-Diagnostic")] +internal sealed class DiagnosticEvents : EventSource +{ + public static readonly DiagnosticEvents Log = new(); + [Event(1, Level = EventLevel.Informational)] + public void Clock(long qpc, long frequency) => WriteEvent(1, qpc, frequency); + [Event(2, Level = EventLevel.Informational)] + public void Block(string api, string wire, int round, int active, long qpc) => WriteEvent(2, api, wire, round, active, qpc); +} diff --git a/tests/CSharpDB.Observability.Tests/OperationScopeTests.cs b/tests/CSharpDB.Observability.Tests/OperationScopeTests.cs index 8d52ac2e..5a7fa07e 100644 --- a/tests/CSharpDB.Observability.Tests/OperationScopeTests.cs +++ b/tests/CSharpDB.Observability.Tests/OperationScopeTests.cs @@ -2,6 +2,8 @@ namespace CSharpDB.Observability.Tests; +// Exact callback counts require isolation from other process-wide listener users. +[Collection(MetricsContractCollection.Name)] public sealed class OperationScopeTests { [Fact] diff --git a/tests/CSharpDB.Tests/AdminRoutedGrpcClientTests.cs b/tests/CSharpDB.Tests/AdminRoutedGrpcClientTests.cs new file mode 100644 index 00000000..57d8476e --- /dev/null +++ b/tests/CSharpDB.Tests/AdminRoutedGrpcClientTests.cs @@ -0,0 +1,69 @@ +using CSharpDB.Admin.Configuration; +using CSharpDB.Admin.Services; +using CSharpDB.Client; +using CSharpDB.Client.Internal; +using CSharpDB.Primitives; + +namespace CSharpDB.Tests; + +public sealed class AdminRoutedGrpcClientTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RoutedClients_PreserveNativeStreamingMode(bool capture) + { + var options = new CSharpDbClientOptions + { + Transport = CSharpDbTransport.Grpc, + Endpoint = "http://127.0.0.1:5821", + GrpcMode = CSharpDbGrpcMode.Native, + GrpcCallMode = CSharpDbGrpcCallMode.Streaming, + }; + await using var holder = CreateHolder(options); + + await CheckRoutedModeAsync(holder, capture, expectedStreaming: true); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RoutedClients_KeepUnaryAsTheDefault(bool capture) + { + var options = new CSharpDbClientOptions + { + Transport = CSharpDbTransport.Grpc, + Endpoint = "http://127.0.0.1:5821", + GrpcMode = CSharpDbGrpcMode.Native, + }; + await using var holder = CreateHolder(options); + + await CheckRoutedModeAsync(holder, capture, expectedStreaming: false); + } + + private static DatabaseClientHolder CreateHolder(CSharpDbClientOptions options) + => new( + CSharpDbClient.Create(options), + shardAdmin: null, + baseClientOptions: options, + hostDatabaseOptions: new AdminHostDatabaseOptions(), + functions: DbFunctionRegistry.Create(_ => { })); + + private static async Task CheckRoutedModeAsync( + DatabaseClientHolder holder, bool capture, bool expectedStreaming) + { + var route = new CSharpDbRouteContext { Keyspace = "tenants", Key = "one" }; + if (capture) + { + await using DatabaseClientHolder.ClientLease lease = holder.CaptureClient(route); + Assert.Equal(expectedStreaming, + Assert.IsAssignableFrom(lease.Client).SupportsScalarProjection); + } + else + { + await using ICSharpDbClient client = holder.CreateRouteBoundClient(route); + Assert.Equal(expectedStreaming, + Assert.IsAssignableFrom(client).SupportsScalarProjection); + } + } +} diff --git a/tests/CSharpDB.Tests/ClientDiagnosticsTestScope.cs b/tests/CSharpDB.Tests/ClientDiagnosticsTestScope.cs new file mode 100644 index 00000000..18e0e31a --- /dev/null +++ b/tests/CSharpDB.Tests/ClientDiagnosticsTestScope.cs @@ -0,0 +1,14 @@ +using System.Reflection; +using CSharpDB.Client.Internal; + +namespace CSharpDB.Tests; + +internal static class ClientDiagnosticsTestScope +{ + // Preserve a sampled runtime family across a physical owner release. This + // exercises aggregation without keeping a database transaction open. + internal static IDisposable RetainRuntimeFamily(EngineTransportClient client) + => Assert.IsAssignableFrom(typeof(EngineTransportClient) + .GetMethod("AcquireRuntimeDiagnosticsFamilyLease", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(client, null)); +} diff --git a/tests/CSharpDB.Tests/ClientRuntimeDiagnosticsCapabilityTests.cs b/tests/CSharpDB.Tests/ClientRuntimeDiagnosticsCapabilityTests.cs index 50022eef..27a6ba61 100644 --- a/tests/CSharpDB.Tests/ClientRuntimeDiagnosticsCapabilityTests.cs +++ b/tests/CSharpDB.Tests/ClientRuntimeDiagnosticsCapabilityTests.cs @@ -13,6 +13,9 @@ public sealed class ClientRuntimeDiagnosticsCapabilityTests { private static CancellationToken Ct => TestContext.Current.CancellationToken; + private static IDisposable RetainDiagnosticsCapture(EngineTransportClient client) + => ClientDiagnosticsTestScope.RetainRuntimeFamily(client); + [Fact] public async Task DisabledDirectCapability_IsStableExplicitAndDoesNotOpenDatabase() { @@ -223,7 +226,7 @@ record => record.Fingerprint == } [Fact] - public async Task RetainedTransactionFamily_RemainsVisibleAlongsideReplacementFamily() + public async Task RetainedDiagnosticsCapture_RemainsVisibleAlongsideReplacementFamily() { const string retainedDetailSql = "SELECT 1 AS retained_family_detail, 2 AS retained_family_second_value"; @@ -243,8 +246,14 @@ public async Task RetainedTransactionFamily_RemainsVisibleAlongsideReplacementFa Ct)).Error); Assert.Null((await client.ExecuteSqlAsync("SELECT 10", Ct)).Error); + // A diagnostics capture may outlive the handle it sampled. A live + // transaction may no longer release the shared database owner. + using IDisposable retainedCapture = RetainDiagnosticsCapture(client); + await client.RollbackTransactionAsync(transaction.TransactionId, Ct); await client.ReleaseCachedDatabaseAsync(Ct); Assert.Null((await client.ExecuteSqlAsync("SELECT 2", Ct)).Error); + transaction = await client.BeginTransactionAsync(Ct); + transactionId = transaction.TransactionId; cursor = Assert.IsType( await client.TryOpenForwardOnlyQueryCursorAsync( transaction.TransactionId, @@ -414,6 +423,7 @@ public async Task AggregateCollections_PreserveFamilyDropsAndCallerTruncation() options); var diagnostics = Assert.IsAssignableFrom(client); TransactionSessionInfo transaction = await client.BeginTransactionAsync(Ct); + using IDisposable retainedCapture = RetainDiagnosticsCapture(client); ForwardOnlyQueryCursor? transactionCursor = null; ForwardOnlyQueryCursor? directCursor = null; try @@ -423,8 +433,10 @@ public async Task AggregateCollections_PreserveFamilyDropsAndCallerTruncation() "SELECT 1", Ct)).Error); Assert.Null((await client.ExecuteSqlAsync("SELECT 2", Ct)).Error); + await client.RollbackTransactionAsync(transaction.TransactionId, Ct); await client.ReleaseCachedDatabaseAsync(Ct); Assert.Null((await client.ExecuteSqlAsync("SELECT 3", Ct)).Error); + transaction = await client.BeginTransactionAsync(Ct); CSharpDbRuntimeDiagnosticsState[] states; lock (openedStates) @@ -506,145 +518,42 @@ await client.TryOpenForwardOnlyQueryCursorAsync( } [Fact] - public async Task MixedFamilyEnablement_PreservesExactEnabledDataWithoutStartingDisabledRegistry() + public async Task ObservabilityEnablement_ChangesOnlyAfterSharedTransactionsFinish() { - await using var client = CreateClient(CreateOptions("mixed-enablement")); + await using var client = CreateClient(CreateOptions("enablement-boundary")); var diagnostics = Assert.IsAssignableFrom(client); - CSharpDbObservabilityOptions mutableOptions = - client.DirectDatabaseOptions.ObservabilityOptions!; - string? transactionId = null; - ForwardOnlyQueryCursor? cursor = null; - try - { - TransactionSessionInfo enabledTransaction = await client.BeginTransactionAsync(Ct); - transactionId = enabledTransaction.TransactionId; - Assert.Null((await client.ExecuteInTransactionAsync( - transactionId, - "SELECT 1", - Ct)).Error); - Assert.Null((await client.ExecuteSqlAsync("SELECT 2", Ct)).Error); - - mutableOptions.Enabled = false; - await client.ReleaseCachedDatabaseAsync(Ct); - CSharpDbRuntimeDiagnosticsState disabledState = - Assert.IsType( - client.CurrentRuntimeDiagnosticsState); - Assert.False(disabledState.IsEnabled); - Assert.Null(GetRuntimeComponents(disabledState)); - - cursor = Assert.IsType( - await client.TryOpenForwardOnlyQueryCursorAsync( - transactionId, - "SELECT 4", - Ct)); - DiagnosticsTopologySnapshot> - pendingReplacementActive = await diagnostics.GetActiveQueriesAsync(10, Ct); - Assert.Equal(DiagnosticsScope.Aggregate, pendingReplacementActive.Metadata.Scope); - RuntimeDiagnosticsFamilySection> - establishedFamily = Assert.Single(pendingReplacementActive.RuntimeFamilies!); - Assert.Equal( - DiagnosticsAvailability.Available, - establishedFamily.Value.Metadata.Availability); - Assert.Single(establishedFamily.Value.Records!); - Assert.Null(GetRuntimeComponents(disabledState)); - - Assert.Null((await client.ExecuteSqlAsync("SELECT 3", Ct)).Error); - DiagnosticsTopologySnapshot disabledCurrentRuntime = - await diagnostics.GetRuntimeDiagnosticsAsync(Ct); - DiagnosticsTopologySnapshot> - disabledCurrentActive = await diagnostics.GetActiveQueriesAsync(10, Ct); - DiagnosticsTopologySnapshot> - disabledCurrentRecent = await diagnostics.GetRecentQueriesAsync(10, Ct); - DiagnosticsTopologySnapshot> - disabledCurrentSessions = await diagnostics.GetSessionsAsync(10, Ct); - - Assert.Equal(DiagnosticsScope.Aggregate, disabledCurrentRuntime.Metadata.Scope); - Assert.Equal(DiagnosticsAvailability.Available, disabledCurrentRuntime.Metadata.Availability); - Assert.Equal( - [DiagnosticsAvailability.Available, DiagnosticsAvailability.Disabled], - disabledCurrentRuntime.RuntimeFamilies! - .Select(static family => family.Value.Metadata.Availability) - .Order() - .ToArray()); - Assert.Equal(DiagnosticsScope.Aggregate, disabledCurrentActive.Metadata.Scope); - Assert.Single(disabledCurrentActive.Aggregate.Records!); - Assert.Contains( - disabledCurrentActive.RuntimeFamilies!, - static family => - family.Value.Metadata.Availability == DiagnosticsAvailability.Disabled && - family.Value.Records is null); - Assert.NotEmpty(disabledCurrentRecent.Aggregate.Records!); - Assert.Equal(DiagnosticsScope.Instance, disabledCurrentSessions.Metadata.Scope); - Assert.Equal( - DiagnosticsAvailability.Disabled, - disabledCurrentSessions.Metadata.Availability); - Assert.Null(disabledCurrentSessions.Aggregate.Records); - Assert.Null(GetRuntimeComponents(disabledState)); + CSharpDbObservabilityOptions mutableOptions = client.DirectDatabaseOptions.ObservabilityOptions!; + CSharpDbRuntimeDiagnosticsState enabledState = client.CurrentRuntimeDiagnosticsState!; + TransactionSessionInfo enabledTransaction = await client.BeginTransactionAsync(Ct); + Assert.Null((await client.ExecuteInTransactionAsync(enabledTransaction.TransactionId, "SELECT 1", Ct)).Error); + mutableOptions.Enabled = false; + await Assert.ThrowsAsync(async () => await client.ReleaseCachedDatabaseAsync(Ct)); + Assert.Same(enabledState, client.CurrentRuntimeDiagnosticsState); + Assert.Equal(DiagnosticsAvailability.Available, (await diagnostics.GetRuntimeDiagnosticsAsync(Ct)).Metadata.Availability); + await client.RollbackTransactionAsync(enabledTransaction.TransactionId, Ct); + await client.ReleaseCachedDatabaseAsync(Ct); - await cursor.DisposeAsync(); - cursor = null; - await client.RollbackTransactionAsync(transactionId, Ct); - transactionId = null; - - TransactionSessionInfo disabledTransaction = await client.BeginTransactionAsync(Ct); - transactionId = disabledTransaction.TransactionId; - Assert.Same( - disabledState, - client.CurrentRuntimeDiagnosticsState); - Assert.Null((await client.ExecuteInTransactionAsync( - transactionId, - "SELECT 5", - Ct)).Error); - Assert.Null((await client.ExecuteSqlAsync("SELECT 6", Ct)).Error); - - mutableOptions.Enabled = true; - await client.ReleaseCachedDatabaseAsync(Ct); - Assert.Null((await client.ExecuteSqlAsync("SELECT 7", Ct)).Error); - CSharpDbRuntimeDiagnosticsState reenabledState = - Assert.IsType( - client.CurrentRuntimeDiagnosticsState); - Assert.True(reenabledState.IsEnabled); - Assert.NotSame(disabledState, reenabledState); - - cursor = Assert.IsType( - await client.TryOpenForwardOnlyQueryCursorAsync("SELECT 8", Ct)); - DiagnosticsTopologySnapshot enabledCurrentRuntime = - await diagnostics.GetRuntimeDiagnosticsAsync(Ct); - DiagnosticsTopologySnapshot> - enabledCurrentActive = await diagnostics.GetActiveQueriesAsync(10, Ct); - DiagnosticsTopologySnapshot> - enabledCurrentSessions = await diagnostics.GetSessionsAsync(10, Ct); - - Assert.Equal(DiagnosticsScope.Aggregate, enabledCurrentRuntime.Metadata.Scope); - Assert.Equal( - [DiagnosticsAvailability.Available, DiagnosticsAvailability.Disabled], - enabledCurrentRuntime.RuntimeFamilies! - .Select(static family => family.Value.Metadata.Availability) - .Order() - .ToArray()); - Assert.Single(enabledCurrentActive.Aggregate.Records!); - Assert.Contains( - enabledCurrentActive.RuntimeFamilies!, - static family => - family.Value.Metadata.Availability == DiagnosticsAvailability.Disabled && - family.Value.Records is null); - Assert.Equal( - DiagnosticsAvailability.Available, - enabledCurrentSessions.Metadata.Availability); - Assert.Contains( - enabledCurrentSessions.Aggregate.Records!, - static session => session.HasActiveTransaction); - Assert.Null(GetRuntimeComponents(disabledState)); - } - finally - { - if (cursor is not null) - await cursor.DisposeAsync(); - if (transactionId is not null) - await client.RollbackTransactionAsync(transactionId, Ct); - } + Assert.Null((await client.ExecuteSqlAsync("SELECT 2", Ct)).Error); + CSharpDbRuntimeDiagnosticsState disabledState = client.CurrentRuntimeDiagnosticsState!; + Assert.False(disabledState.IsEnabled); + Assert.Null(GetRuntimeComponents(disabledState)); + TransactionSessionInfo disabledTransaction = await client.BeginTransactionAsync(Ct); + Assert.Null((await client.ExecuteInTransactionAsync(disabledTransaction.TransactionId, "SELECT 3", Ct)).Error); + mutableOptions.Enabled = true; + await Assert.ThrowsAsync(async () => await client.ReleaseCachedDatabaseAsync(Ct)); + Assert.Same(disabledState, client.CurrentRuntimeDiagnosticsState); + Assert.Equal(DiagnosticsAvailability.Disabled, (await diagnostics.GetRuntimeDiagnosticsAsync(Ct)).Metadata.Availability); + Assert.Null(GetRuntimeComponents(disabledState)); + await client.RollbackTransactionAsync(disabledTransaction.TransactionId, Ct); + await client.ReleaseCachedDatabaseAsync(Ct); + + Assert.Null((await client.ExecuteSqlAsync("SELECT 4", Ct)).Error); + Assert.True(client.CurrentRuntimeDiagnosticsState!.IsEnabled); + Assert.NotSame(disabledState, client.CurrentRuntimeDiagnosticsState); + await using var cursor = await client.TryOpenForwardOnlyQueryCursorAsync("SELECT 5", Ct); + Assert.Single((await diagnostics.GetActiveQueriesAsync(10, Ct)).Aggregate.Records!); + Assert.Equal(0, (await diagnostics.GetRuntimeDiagnosticsAsync(Ct)).Aggregate.Connections.Value!.ActiveTransactions); } - [Fact] public async Task SuppliedDisabledRuntimeState_RetainsIdentityWithoutCreatingComponents() { diff --git a/tests/CSharpDB.Tests/ClientTransactionAdmissionTests.cs b/tests/CSharpDB.Tests/ClientTransactionAdmissionTests.cs new file mode 100644 index 00000000..0d80bbf5 --- /dev/null +++ b/tests/CSharpDB.Tests/ClientTransactionAdmissionTests.cs @@ -0,0 +1,273 @@ +using System.Collections.Concurrent; +using System.Reflection; +using CSharpDB.Client; +using CSharpDB.Client.Internal; +using CSharpDB.Engine; +using CSharpDB.Primitives; + +namespace CSharpDB.Tests; + +public sealed class ClientTransactionAdmissionTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); + + [Fact] + public async Task CancellationAtCompletionHandoff_RestoresSessionForRollback() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var release = new ManualResetEventSlim(); + var options = new DatabaseOptions().ConfigureFunctions(functions => functions.AddScalar( + "HoldForCompletion", 0, new DbScalarFunctionOptions(DbType.Integer), (_, _) => + { + entered.TrySetResult(); + if (!release.Wait(Timeout, Ct)) throw new TimeoutException("Query was not released."); + return DbValue.FromInteger(1); + })); + await using var client = EngineTransportClient.CreatePrivateMemory("completion-handoff", null, options); + using var cancel = CancellationTokenSource.CreateLinkedTokenSource(Ct); + var context = new QueuedContext(); + string id = (await client.BeginTransactionAsync(Ct)).TransactionId; + Task query = Task.Run(() => client.ExecuteInTransactionAsync(id, "SELECT HoldForCompletion();", Ct), Ct); + Task completion = Task.CompletedTask; + try + { + await entered.Task.WaitAsync(Timeout, Ct); + SynchronizationContext? previous = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(context); + try { completion = client.CommitTransactionAsync(id, cancel.Token); } + finally { SynchronizationContext.SetSynchronizationContext(previous); } + release.Set(); + await query.WaitAsync(Timeout, Ct); + // The semaphore has handed off, but the completion continuation + // cannot run until we release it. Cancellation must still win. + await context.Posted.WaitAsync(Timeout, Ct); + cancel.Cancel(); + await context.DrainUntilAsync(completion); + var error = await Assert.ThrowsAnyAsync(() => completion); + Assert.Equal(cancel.Token, error.CancellationToken); + await client.RollbackTransactionAsync(id, Ct); + } + finally + { + release.Set(); + cancel.Cancel(); + await context.DrainUntilAsync(completion); + await query.WaitAsync(Timeout, Ct); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task PendingSchemaBegin_DoesNotBlockCompletion(bool commit) + { + await using var client = EngineTransportClient.CreatePrivateMemory("admission-completion", null, null); + using var cancelBegin = CancellationTokenSource.CreateLinkedTokenSource(Ct); + string first = (await client.BeginTransactionAsync(Ct)).TransactionId; + await client.ExecuteInTransactionAsync(first, "CREATE TABLE admitted (id INTEGER PRIMARY KEY);", Ct); + Task pending = client.BeginTransactionAsync(cancelBegin.Token); + Task completion = Task.CompletedTask; + try + { + Assert.False(pending.IsCompleted); + completion = commit ? client.CommitTransactionAsync(first, Ct) : client.RollbackTransactionAsync(first, Ct); + await completion.WaitAsync(Timeout, Ct); + string second = (await pending.WaitAsync(Timeout, Ct)).TransactionId; + await client.RollbackTransactionAsync(second, Ct); + Assert.Equal(commit, (await client.GetTableNamesAsync(Ct)).Contains("admitted")); + } + finally + { + cancelBegin.Cancel(); + await ObserveCanceledBeginAsync(pending); + await completion.WaitAsync(Timeout, Ct); + } + } + + [Fact] + public async Task Disposal_CancelsPendingSchemaBegin_AndDrainsItBeforeClosingOwner() + { + var client = EngineTransportClient.CreatePrivateMemory("admission-disposal", null, null); + using var cancelBegin = CancellationTokenSource.CreateLinkedTokenSource(Ct); + Task? pending = null; + Task disposal = Task.CompletedTask; + try + { + string first = (await client.BeginTransactionAsync(Ct)).TransactionId; + await client.ExecuteInTransactionAsync(first, "CREATE TABLE disposed_admission (id INTEGER);", Ct); + pending = client.BeginTransactionAsync(cancelBegin.Token); + Assert.False(pending.IsCompleted); + disposal = client.DisposeAsync().AsTask(); + await disposal.WaitAsync(Timeout, Ct); + await Assert.ThrowsAsync(() => pending); + } + finally + { + cancelBegin.Cancel(); + if (pending is not null) await ObserveCanceledBeginAsync(pending); + await disposal.WaitAsync(Timeout, Ct); + await client.DisposeAsync(); + } + } + + [Fact] + public async Task CanceledPendingSchemaBegin_PreservesCallerTokenAndOwnerTransaction() + { + await using var client = EngineTransportClient.CreatePrivateMemory("admission-cancellation", null, null); + using var cancelBegin = CancellationTokenSource.CreateLinkedTokenSource(Ct); + string first = (await client.BeginTransactionAsync(Ct)).TransactionId; + await client.ExecuteInTransactionAsync(first, "CREATE TABLE retained_admission (id INTEGER);", Ct); + Task pending = client.BeginTransactionAsync(cancelBegin.Token); + Assert.False(pending.IsCompleted); + cancelBegin.Cancel(); + var error = await Assert.ThrowsAnyAsync(() => pending); + Assert.Equal(cancelBegin.Token, error.CancellationToken); + await client.CommitTransactionAsync(first, Ct).WaitAsync(Timeout, Ct); + string next = (await client.BeginTransactionAsync(Ct)).TransactionId; + await client.RollbackTransactionAsync(next, Ct); + Assert.Contains("retained_admission", await client.GetTableNamesAsync(Ct)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task UnpublishedAdmission_IsRolledBackWhenCancellationOrDisposalWins(bool dispose) + { + var client = EngineTransportClient.CreatePrivateMemory("admission-publication-race", null, null); + using var cancelBegin = CancellationTokenSource.CreateLinkedTokenSource(Ct); + Task? pending = null; + Task disposal = Task.CompletedTask; + SemaphoreSlim gate = ClientGate(client); + bool gateHeld = false; + try + { + await client.GetInfoAsync(Ct); + Database database = (await client.TryGetDatabaseAsync(Ct))!; + await using var schemaOwner = await database.BeginWriteTransactionAsync(Ct); + await using (var result = await schemaOwner.ExecuteAsync("CREATE TABLE publication_race (id INTEGER);", Ct)) { } + pending = client.BeginTransactionAsync(cancelBegin.Token); + Assert.False(pending.IsCompleted); + using var gateDeadline = CancellationTokenSource.CreateLinkedTokenSource(Ct); + gateDeadline.CancelAfter(Timeout); + await gate.WaitAsync(gateDeadline.Token); + gateHeld = true; + if (dispose) disposal = client.DisposeAsync().AsTask(); + await schemaOwner.RollbackAsync(Ct); + await schemaOwner.DisposeAsync(); + await WaitUntilAsync(() => database.ActiveReaderCount == 1); + // The only snapshot now belongs to the admitted but unpublished + // begin. The held gate prevents it from registering its session. + if (!dispose) + { + cancelBegin.Cancel(); + await WaitUntilAsync(() => database.ActiveReaderCount == 0); + } + gate.Release(); + gateHeld = false; + if (dispose) + { + await disposal.WaitAsync(Timeout, Ct); + await Assert.ThrowsAsync(() => pending); + } + else + { + var error = await Assert.ThrowsAnyAsync(() => pending); + Assert.Equal(cancelBegin.Token, error.CancellationToken); + string next = (await client.BeginTransactionAsync(Ct)).TransactionId; + await client.RollbackTransactionAsync(next, Ct); + } + } + finally + { + cancelBegin.Cancel(); + if (gateHeld) gate.Release(); + if (pending is not null) await ObserveCanceledBeginAsync(pending); + await disposal.WaitAsync(Timeout, Ct); + await client.DisposeAsync(); + } + } + + [Fact] + public async Task PendingAdmission_PreventsCachedOwnerReleaseAndExclusiveMaintenance() + { + string path = Path.Combine(Path.GetTempPath(), $"csharpdb_admission_owner_{Guid.NewGuid():N}.db"); + try + { + await using var client = new EngineTransportClient(path); + using var cancelBegin = CancellationTokenSource.CreateLinkedTokenSource(Ct); + await client.GetInfoAsync(Ct); + Database database = (await client.TryGetDatabaseAsync(Ct))!; + await using var schemaOwner = await database.BeginWriteTransactionAsync(Ct); + await using (var result = await schemaOwner.ExecuteAsync("CREATE TABLE external_schema_owner (id INTEGER);", Ct)) { } + Task pending = client.BeginTransactionAsync(cancelBegin.Token); + try + { + Assert.False(pending.IsCompleted); + using var operationDeadline = CancellationTokenSource.CreateLinkedTokenSource(Ct); + operationDeadline.CancelAfter(Timeout); + await Assert.ThrowsAsync(() => client.ReleaseCachedDatabaseAsync(operationDeadline.Token).AsTask()); + var error = await Assert.ThrowsAnyAsync(() => client.OpenExclusiveSessionAsync(operationDeadline.Token).AsTask()); + Assert.Contains("Finish active transactions", error.Message, StringComparison.Ordinal); + Assert.Same(database, await client.TryGetDatabaseAsync(Ct)); + } + finally + { + cancelBegin.Cancel(); + await ObserveCanceledBeginAsync(pending); + await schemaOwner.RollbackAsync(Ct); + } + } + finally + { + File.Delete(path); + File.Delete(path + ".wal"); + } + } + + private static SemaphoreSlim ClientGate(EngineTransportClient client) => + (SemaphoreSlim)typeof(EngineTransportClient).GetField("_lock", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(client)!; + + private static async Task WaitUntilAsync(Func condition) + { + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(Ct); + deadline.CancelAfter(Timeout); + while (!condition()) await Task.Delay(1, deadline.Token); + } + + private static async Task ObserveCanceledBeginAsync(Task pending) + { + try { await pending.WaitAsync(Timeout, Ct); } + catch (OperationCanceledException) { } + catch (ObjectDisposedException) { } + } + + private sealed class QueuedContext : SynchronizationContext + { + private readonly ConcurrentQueue<(SendOrPostCallback Callback, object? State)> _callbacks = new(); + private readonly TaskCompletionSource _posted = new(TaskCreationOptions.RunContinuationsAsynchronously); + public Task Posted => _posted.Task; + public override void Post(SendOrPostCallback callback, object? state) + { + _callbacks.Enqueue((callback, state)); + _posted.TrySetResult(); + } + + public async Task DrainUntilAsync(Task task) + { + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(Ct); + deadline.CancelAfter(Timeout); + while (!task.IsCompleted) + { + while (_callbacks.TryDequeue(out var callback)) + { + SynchronizationContext? previous = Current; + SetSynchronizationContext(this); + try { callback.Callback(callback.State); } + finally { SetSynchronizationContext(previous); } + } + if (!task.IsCompleted) await Task.Delay(1, deadline.Token); + } + } + } +} diff --git a/tests/CSharpDB.Tests/ClientTransactionOwnershipTests.cs b/tests/CSharpDB.Tests/ClientTransactionOwnershipTests.cs new file mode 100644 index 00000000..23e9d623 --- /dev/null +++ b/tests/CSharpDB.Tests/ClientTransactionOwnershipTests.cs @@ -0,0 +1,167 @@ +using CSharpDB.Client.Internal; +using CSharpDB.Engine; +using CSharpDB.Primitives; + +namespace CSharpDB.Tests; + +public sealed class ClientTransactionOwnershipTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task CanceledCompletionDuringDisposal_WaitsForTheInFlightOperation() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var release = new ManualResetEventSlim(); + var options = new DatabaseOptions().ConfigureFunctions(functions => functions.AddScalar( + "HoldTransaction", 0, new DbScalarFunctionOptions(DbType.Integer), (_, _) => + { + entered.TrySetResult(); + if (!release.Wait(TimeSpan.FromSeconds(10), Ct)) + throw new TimeoutException("Transaction operation was not released."); + return DbValue.FromInteger(1); + })); + var client = EngineTransportClient.CreatePrivateMemory(":memory:dispose-cancel", null, options); + try + { + string id = (await client.BeginTransactionAsync(Ct)).TransactionId; + Task query = Task.Run(() => client.ExecuteInTransactionAsync(id, "SELECT HoldTransaction();", Ct), Ct); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(10), Ct); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(Ct); + Task commit = client.CommitTransactionAsync(id, cancellation.Token); + Task dispose = client.DisposeAsync().AsTask(); + cancellation.Cancel(); + Assert.False(commit.IsCompleted); + Assert.False(dispose.IsCompleted); + release.Set(); + await query; + await Assert.ThrowsAnyAsync(() => commit); + await dispose; + } + finally + { + release.Set(); + await client.DisposeAsync(); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task EightOverlappingTransactions_ShareOneOwner_AndSurviveOtherSessionsCompleting(bool hybrid) + { + string path = Path.Combine(Path.GetTempPath(), $"csharpdb_shared_transactions_{Guid.NewGuid():N}.db"); + int opens = 0; + try + { + await using (var client = new EngineTransportClient(path, async (file, ct) => + { + Interlocked.Increment(ref opens); + return hybrid ? await Database.OpenHybridAsync(file, ct) : await Database.OpenAsync(file, ct); + })) + { + Assert.Null((await client.ExecuteSqlAsync("CREATE TABLE ownership (id INTEGER PRIMARY KEY);", Ct)).Error); + var transactions = new List(); + for (int i = 0; i < 8; i++) + transactions.Add((await client.BeginTransactionAsync(Ct)).TransactionId); + + for (int i = 0; i < transactions.Count; i++) + await client.ExecuteInTransactionAsync(transactions[i], $"INSERT INTO ownership VALUES ({i});", Ct); + + // All sessions remain open when the first commit finishes. Closing + // an independently opened database here used to delete their WAL. + foreach (string transaction in transactions) + await client.CommitTransactionAsync(transaction, Ct); + + Assert.Equal(1, opens); + Assert.Equal(8, await client.GetRowCountAsync("ownership", Ct)); + } + + await using var reopened = await Database.OpenAsync(path, Ct); + await using var result = await reopened.ExecuteAsync("SELECT id FROM ownership ORDER BY id;", Ct); + Assert.Equal(Enumerable.Range(0, 8).Select(i => (long)i), + (await result.ToListAsync(Ct)).Select(row => row[0].AsInteger)); + } + finally + { + foreach (string file in new[] { path, path + ".wal" }) + { + try { File.Delete(file); } + catch (IOException) { } // Preserve the original ownership failure. + } + } + } + + [Fact] + public async Task SnapshotHybrid_RejectsOverlappingSessions_AndPersistsCompletedTransaction() + { + string path = Path.Combine(Path.GetTempPath(), $"csharpdb_snapshot_transaction_{Guid.NewGuid():N}.db"); + try + { + await using (var client = new EngineTransportClient(path, hybridDatabaseOptions: new HybridDatabaseOptions + { PersistenceMode = HybridPersistenceMode.Snapshot, PersistenceTriggers = HybridPersistenceTriggers.Dispose })) + { + string id = (await client.BeginTransactionAsync(Ct)).TransactionId; + var busy = await Assert.ThrowsAsync(() => client.BeginTransactionAsync(Ct)); + Assert.Equal(ErrorCode.Busy, busy.Code); + await Assert.ThrowsAsync(() => client.GetTableNamesAsync(Ct)); + await client.ExecuteInTransactionAsync(id, "CREATE TABLE snapshot_owner (id INTEGER PRIMARY KEY);", Ct); + await client.ExecuteInTransactionAsync(id, "INSERT INTO snapshot_owner VALUES (1);", Ct); + await client.CommitTransactionAsync(id, Ct); + Assert.Equal(1, await client.GetRowCountAsync("snapshot_owner", Ct)); + } + await using var reopened = await Database.OpenAsync(path, Ct); + await using var result = await reopened.ExecuteAsync("SELECT id FROM snapshot_owner;", Ct); + Assert.Equal(1L, Assert.Single(await result.ToListAsync(Ct))[0].AsInteger); + } + finally + { + File.Delete(path); + File.Delete(path + ".wal"); + } + } + + [Fact] + public async Task OverlappingUpdates_ReturnConflict_WithoutLosingCommittedState() + { + await using var client = EngineTransportClient.CreatePrivateMemory(":memory:conflicts", null, null); + Assert.Null((await client.ExecuteSqlAsync( + "CREATE TABLE conflicts (id INTEGER PRIMARY KEY, value INTEGER); INSERT INTO conflicts VALUES (1, 0);", Ct)).Error); + string first = (await client.BeginTransactionAsync(Ct)).TransactionId; + string second = (await client.BeginTransactionAsync(Ct)).TransactionId; + await client.ExecuteInTransactionAsync(first, "UPDATE conflicts SET value = 1 WHERE id = 1;", Ct); + await client.ExecuteInTransactionAsync(second, "UPDATE conflicts SET value = 2 WHERE id = 1;", Ct); + await client.CommitTransactionAsync(first, Ct); + var conflict = await Assert.ThrowsAnyAsync(() => client.CommitTransactionAsync(second, Ct)); + Assert.Equal(ErrorCode.TransactionConflict, conflict.Code); + Assert.Equal(1L, (await client.ExecuteSqlAsync("SELECT value FROM conflicts WHERE id = 1;", Ct)).Rows![0][0]); + + string next = (await client.BeginTransactionAsync(Ct)).TransactionId; + await client.ExecuteInTransactionAsync(next, "UPDATE conflicts SET value = 3 WHERE id = 1;", Ct); + await client.CommitTransactionAsync(next, Ct); + Assert.Equal(3L, (await client.ExecuteSqlAsync("SELECT value FROM conflicts WHERE id = 1;", Ct)).Rows![0][0]); + } + + [Fact] + public async Task TemporaryTables_StayIsolated_WhenAnotherTransactionCompletes() + { + await using var client = EngineTransportClient.CreatePrivateMemory(":memory:temporary", null, null); + string first = (await client.BeginTransactionAsync(Ct)).TransactionId; + string second = (await client.BeginTransactionAsync(Ct)).TransactionId; + foreach (string transaction in new[] { first, second }) + await client.ExecuteInTransactionAsync(transaction, "CREATE TEMP TABLE isolated (id INTEGER);", Ct); + await client.ExecuteInTransactionAsync(first, "INSERT INTO isolated VALUES (1);", Ct); + await client.ExecuteInTransactionAsync(second, "INSERT INTO isolated VALUES (2);", Ct); + await client.CommitTransactionAsync(first, Ct); + // Exercise deferred cursor execution, which must retain the session scope. + await using (var cursor = await client.TryOpenForwardOnlyQueryCursorAsync(second, "SELECT id FROM isolated;", Ct)) + { + Assert.NotNull(cursor); + Assert.Equal(2L, Assert.Single(await cursor.ReadNextAsync(10, Ct))[0]); + } + await client.RollbackTransactionAsync(second, Ct); + string third = (await client.BeginTransactionAsync(Ct)).TransactionId; + await Assert.ThrowsAsync(() => client.ExecuteInTransactionAsync(third, "SELECT * FROM isolated;", Ct)); + await client.RollbackTransactionAsync(third, Ct); + } +} diff --git a/tests/CSharpDB.Tests/EngineTransportClientTests.cs b/tests/CSharpDB.Tests/EngineTransportClientTests.cs index 93e9cdbd..6e818a94 100644 --- a/tests/CSharpDB.Tests/EngineTransportClientTests.cs +++ b/tests/CSharpDB.Tests/EngineTransportClientTests.cs @@ -1222,7 +1222,7 @@ await client.ExecuteInTransactionAsync( } [Fact] - public async Task ClientManagedTransactionFailure_DoesNotRecycleUncertainDatabaseState() + public async Task ClientManagedTransactionFailure_RollsBackIsolatedStateAndKeepsSharedOwner() { string dbPath = Path.Combine(Path.GetTempPath(), $"csharpdb_engine_transport_failed_reuse_{Guid.NewGuid():N}.db"); int openCount = 0; @@ -1260,7 +1260,7 @@ await client.CommitTransactionAsync( await client.GetRowCountAsync( "failed_transaction_reuse", TestContext.Current.CancellationToken)); - Assert.Equal(2, Volatile.Read(ref openCount)); + Assert.Equal(1, Volatile.Read(ref openCount)); } finally { @@ -1269,51 +1269,32 @@ await client.GetRowCountAsync( } [Fact] - public async Task ClientManagedTransactionCompletion_PreservesCompetingOrdinaryHandle() + public async Task ClientManagedTransactionCompletion_SharesOrdinaryOwnerWithoutExposingUncommittedRows() { - Database competingDatabase = await Database.OpenInMemoryAsync( - TestContext.Current.CancellationToken); - bool competingDatabaseHandedOff = false; int openCount = 0; - - try - { - await using var client = new EngineTransportClient( - ":memory:competing-handle", - async (_, ct) => - { - if (Interlocked.Increment(ref openCount) == 2) - { - competingDatabaseHandedOff = true; - return competingDatabase; - } - - return await Database.OpenInMemoryAsync(ct); - }); - - Assert.Null((await client.ExecuteSqlAsync( - "CREATE TABLE competing_handle (id INTEGER PRIMARY KEY);", - TestContext.Current.CancellationToken)).Error); - - TransactionSessionInfo transaction = await client.BeginTransactionAsync(TestContext.Current.CancellationToken); - Assert.Empty(await client.GetTableNamesAsync(TestContext.Current.CancellationToken)); - Assert.Equal(2, Volatile.Read(ref openCount)); - - await client.RollbackTransactionAsync(transaction.TransactionId, TestContext.Current.CancellationToken); - Assert.Same( - competingDatabase, - await client.TryGetDatabaseAsync(TestContext.Current.CancellationToken)); - Assert.Equal(2, Volatile.Read(ref openCount)); - } - finally - { - if (!competingDatabaseHandedOff) - await competingDatabase.DisposeAsync(); - } + await using var client = new EngineTransportClient( + ":memory:shared-owner", + async (_, ct) => + { + Interlocked.Increment(ref openCount); + return await Database.OpenInMemoryAsync(ct); + }); + Assert.Null((await client.ExecuteSqlAsync( + "CREATE TABLE shared_owner (id INTEGER PRIMARY KEY); INSERT INTO shared_owner VALUES (1);", + TestContext.Current.CancellationToken)).Error); + Database? owner = await client.TryGetDatabaseAsync(TestContext.Current.CancellationToken); + TransactionSessionInfo transaction = await client.BeginTransactionAsync(TestContext.Current.CancellationToken); + await client.ExecuteInTransactionAsync(transaction.TransactionId, + "INSERT INTO shared_owner VALUES (2);", TestContext.Current.CancellationToken); + Assert.Equal(1, await client.GetRowCountAsync("shared_owner", TestContext.Current.CancellationToken)); + Assert.Same(owner, await client.TryGetDatabaseAsync(TestContext.Current.CancellationToken)); + await client.RollbackTransactionAsync(transaction.TransactionId, TestContext.Current.CancellationToken); + Assert.Equal(1, await client.GetRowCountAsync("shared_owner", TestContext.Current.CancellationToken)); + Assert.Equal(1, Volatile.Read(ref openCount)); } [Fact] - public async Task ClientManagedTransactionCompletion_DoesNotAdoptAfterTransientOrdinaryOpen() + public async Task ClientManagedTransactionCompletion_RejectsReleaseUntilTransactionEnds() { int openCount = 0; @@ -1327,16 +1308,18 @@ public async Task ClientManagedTransactionCompletion_DoesNotAdoptAfterTransientO TransactionSessionInfo transaction = await client.BeginTransactionAsync(TestContext.Current.CancellationToken); Assert.Empty(await client.GetTableNamesAsync(TestContext.Current.CancellationToken)); - await client.ReleaseCachedDatabaseAsync(TestContext.Current.CancellationToken); - Assert.Equal(2, Volatile.Read(ref openCount)); + await Assert.ThrowsAsync(async () => + await client.ReleaseCachedDatabaseAsync(TestContext.Current.CancellationToken)); + Assert.Equal(1, Volatile.Read(ref openCount)); await client.RollbackTransactionAsync(transaction.TransactionId, TestContext.Current.CancellationToken); + await client.ReleaseCachedDatabaseAsync(TestContext.Current.CancellationToken); Assert.Empty(await client.GetTableNamesAsync(TestContext.Current.CancellationToken)); - Assert.Equal(3, Volatile.Read(ref openCount)); + Assert.Equal(2, Volatile.Read(ref openCount)); } [Fact] - public async Task OverlappingClientManagedTransactions_DoNotAdoptStaleDatabaseHandle() + public async Task OverlappingClientManagedTransactions_KeepCommittedRowsInSharedOwner() { int openCount = 0; @@ -1348,6 +1331,9 @@ public async Task OverlappingClientManagedTransactions_DoNotAdoptStaleDatabaseHa return await Database.OpenInMemoryAsync(ct); }); + Assert.Null((await client.ExecuteSqlAsync( + "CREATE TABLE shared_overlap (id INTEGER PRIMARY KEY);", + TestContext.Current.CancellationToken)).Error); TransactionSessionInfo first = await client.BeginTransactionAsync( TestContext.Current.CancellationToken); TransactionSessionInfo second = await client.BeginTransactionAsync( @@ -1355,13 +1341,13 @@ public async Task OverlappingClientManagedTransactions_DoNotAdoptStaleDatabaseHa await client.ExecuteInTransactionAsync( first.TransactionId, - "CREATE TABLE stale_overlap_schema (id INTEGER PRIMARY KEY);", + "INSERT INTO shared_overlap VALUES (1);", TestContext.Current.CancellationToken); await client.CommitTransactionAsync(first.TransactionId, TestContext.Current.CancellationToken); await client.RollbackTransactionAsync(second.TransactionId, TestContext.Current.CancellationToken); - Assert.Empty(await client.GetTableNamesAsync(TestContext.Current.CancellationToken)); - Assert.Equal(3, Volatile.Read(ref openCount)); + Assert.Equal(1, await client.GetRowCountAsync("shared_overlap", TestContext.Current.CancellationToken)); + Assert.Equal(1, Volatile.Read(ref openCount)); } [Fact] @@ -1450,6 +1436,11 @@ public async Task ClientManagedTransactionCompletion_WaitsForInFlightStatementBe TestContext.Current.CancellationToken); await Task.Delay(50, TestContext.Current.CancellationToken); Assert.False(commitTask.IsCompleted); + await Assert.ThrowsAsync(async () => + await client.ReleaseCachedDatabaseAsync(TestContext.Current.CancellationToken)); + Exception maintenanceRejection = await Assert.ThrowsAnyAsync(async () => + await client.OpenExclusiveSessionAsync(TestContext.Current.CancellationToken)); + Assert.Contains("Finish active transactions", maintenanceRejection.Message, StringComparison.Ordinal); allowExecute.Set(); SqlExecutionResult result = await executeTask; @@ -1586,7 +1577,7 @@ public async Task DisposeAsync_WaitsForInFlightTransactionStatementAndIsIdempote } [Fact] - public async Task BeginTransaction_ActiveSnapshotReaderRestoresCachedDatabase() + public async Task BeginTransaction_CoexistsWithSnapshotReaderOnSharedDatabase() { string dbPath = Path.Combine(Path.GetTempPath(), $"csharpdb_engine_transport_snapshot_guard_{Guid.NewGuid():N}.db"); int openCount = 0; @@ -1605,9 +1596,9 @@ public async Task BeginTransaction_ActiveSnapshotReaderRestoresCachedDatabase() await client.TryGetDatabaseAsync(TestContext.Current.CancellationToken)); using (Database.ReaderSession reader = database.CreateReaderSession()) { - CSharpDbClientException exception = await Assert.ThrowsAsync( - async () => await client.BeginTransactionAsync(TestContext.Current.CancellationToken)); - Assert.Contains("snapshot readers", exception.Message, StringComparison.OrdinalIgnoreCase); + TransactionSessionInfo overlapping = await client.BeginTransactionAsync(TestContext.Current.CancellationToken); + await client.RollbackTransactionAsync(overlapping.TransactionId, TestContext.Current.CancellationToken); + Assert.Same(database, await client.TryGetDatabaseAsync(TestContext.Current.CancellationToken)); } TransactionSessionInfo transaction = await client.BeginTransactionAsync(TestContext.Current.CancellationToken); diff --git a/tests/CSharpDB.Tests/MaintenanceRuntimeDiagnosticsTests.cs b/tests/CSharpDB.Tests/MaintenanceRuntimeDiagnosticsTests.cs index 19182167..524dc91d 100644 --- a/tests/CSharpDB.Tests/MaintenanceRuntimeDiagnosticsTests.cs +++ b/tests/CSharpDB.Tests/MaintenanceRuntimeDiagnosticsTests.cs @@ -1054,8 +1054,10 @@ public async Task DirectBackup_UsesStateFallbackWithoutCreatingClientDuplicate() try { - _ = await client.BeginTransactionAsync(Ct); + TransactionSessionInfo transaction = await client.BeginTransactionAsync(Ct); Assert.Null((await client.ExecuteSqlAsync("SELECT 1", Ct)).Error); + using IDisposable retainedCapture = ClientDiagnosticsTestScope.RetainRuntimeFamily(client); + await client.RollbackTransactionAsync(transaction.TransactionId, Ct); await client.ReleaseCachedDatabaseAsync(Ct); Assert.Null((await client.ExecuteSqlAsync( "CREATE TABLE direct_backup_items (id INTEGER PRIMARY KEY)", @@ -1161,6 +1163,8 @@ public async Task ValidateOnly_SurvivesFamilyResetAndClientDisposeWithoutExactDu Ct); await validating.Task.WaitAsync(Ct); + using IDisposable retainedCapture = ClientDiagnosticsTestScope.RetainRuntimeFamily(client); + await client.RollbackTransactionAsync(transaction.TransactionId, Ct); await client.ReleaseCachedDatabaseAsync(Ct); Assert.Null((await client.ExecuteSqlAsync("SELECT 2", Ct)).Error); DiagnosticsTopologySnapshot runtime = diff --git a/tests/CSharpDB.Tests/RuntimeDiagnosticsStateOwnershipTests.cs b/tests/CSharpDB.Tests/RuntimeDiagnosticsStateOwnershipTests.cs index 33f2d3f9..6f94ae19 100644 --- a/tests/CSharpDB.Tests/RuntimeDiagnosticsStateOwnershipTests.cs +++ b/tests/CSharpDB.Tests/RuntimeDiagnosticsStateOwnershipTests.cs @@ -982,7 +982,7 @@ await Assert.ThrowsAnyAsync( } [Fact] - public async Task ActiveTransaction_RetainsFrozenFamilyAcrossTransientCachedRelease() + public async Task ActiveTransaction_RejectsReleaseAndRetainsFamilyUntilCompletion() { int openCount = 0; var client = new EngineTransportClient( @@ -1023,42 +1023,26 @@ public async Task ActiveTransaction_RetainsFrozenFamilyAcrossTransientCachedRele Assert.Null(competing.Error); Assert.Equal(2, transactionRegistry.GetRecentSnapshot(10).Records.Count); - await client.ReleaseCachedDatabaseAsync(Ct); - CSharpDbRuntimeDiagnosticsState replacementState = - Assert.IsType( - client.CurrentRuntimeDiagnosticsState); - Assert.NotSame(transactionState, replacementState); - Assert.Equal(0, transactionState.CounterEpoch); - Assert.Equal(0, replacementState.CounterEpoch); + await Assert.ThrowsAsync(async () => await client.ReleaseCachedDatabaseAsync(Ct)); + Assert.Same(transactionState, client.CurrentRuntimeDiagnosticsState); Assert.Equal(0, disposal.DisposeCount); - - SqlExecutionResult replacement = await client.ExecuteSqlAsync("SELECT 3", Ct); - Assert.Null(replacement.Error); - Assert.Equal(0, transactionState.CounterEpoch); - Assert.Equal(1, replacementState.CounterEpoch); - QueryRuntimeDiagnostics replacementRegistry = - QueryRuntimeDiagnostics.GetOrCreate( - replacementState, - startSweepTimer: false); - Assert.Equal( - 2, - transactionRegistry.GetRecentSnapshot(10).Records.Count); - RecentQuerySnapshot replacementTerminal = Assert.Single( - replacementRegistry.GetRecentSnapshot(10).Records); - Assert.Equal(0, Assert.Single( - transactionRegistry.GetRecentSnapshot(10).Records - .Select(static record => record.Metadata.CounterEpoch) - .Distinct())); - Assert.Equal(1, replacementTerminal.Metadata.CounterEpoch); - Assert.Equal( - transactionState.ServerInstanceId, - replacementState.ServerInstanceId); + Assert.Null((await client.ExecuteSqlAsync("SELECT 3", Ct)).Error); + Assert.Equal(3, transactionRegistry.GetRecentSnapshot(10).Records.Count); + Assert.All(transactionRegistry.GetRecentSnapshot(10).Records, + record => Assert.Equal(0, record.Metadata.CounterEpoch)); + Assert.Equal(1, Volatile.Read(ref openCount)); await client.RollbackTransactionAsync(transaction.TransactionId, Ct); transactionId = null; - Assert.Equal(1, disposal.DisposeCount); + Assert.Equal(0, disposal.DisposeCount); Assert.Empty(transactionRegistry.GetActiveSnapshot(10).Records); - Assert.Equal(3, Volatile.Read(ref openCount)); + await client.ReleaseCachedDatabaseAsync(Ct); + CSharpDbRuntimeDiagnosticsState replacementState = client.CurrentRuntimeDiagnosticsState!; + Assert.NotSame(transactionState, replacementState); + Assert.Equal(1, disposal.DisposeCount); + Assert.Null((await client.ExecuteSqlAsync("SELECT 4", Ct)).Error); + Assert.Equal(1, replacementState.CounterEpoch); + Assert.Equal(2, Volatile.Read(ref openCount)); } finally { @@ -1078,7 +1062,7 @@ public async Task ActiveTransaction_RetainsFrozenFamilyAcrossTransientCachedRele } [Fact] - public async Task TransactionQueuedSuccessAndCancellation_StayOnOwningFamilyAfterRelease() + public async Task TransactionQueuedSuccessAndCancellation_StayOnSharedFamilyWhenReleaseIsRejected() { var client = new EngineTransportClient( ":memory:transaction-queued-family", @@ -1104,11 +1088,11 @@ await Database.OpenInMemoryAsync(options, ct), startSweepTimer: false); Assert.Null((await client.ExecuteSqlAsync("SELECT 10", Ct)).Error); - await client.ReleaseCachedDatabaseAsync(Ct); + await Assert.ThrowsAsync(async () => await client.ReleaseCachedDatabaseAsync(Ct)); CSharpDbRuntimeDiagnosticsState pendingReplacement = Assert.IsType( client.CurrentRuntimeDiagnosticsState); - Assert.NotSame(transactionState, pendingReplacement); + Assert.Same(transactionState, pendingReplacement); QueryRuntimeDiagnostics replacementRegistry = QueryRuntimeDiagnostics.GetOrCreate( pendingReplacement, @@ -1163,7 +1147,7 @@ record => record.OperationId == canceledId); } Assert.Empty(replacementRegistry.GetActiveSnapshot(10).Records); - Assert.Empty(replacementRegistry.GetRecentSnapshot(10).Records); + Assert.Same(transactionRegistry, replacementRegistry); Assert.All( transactionRegistry.GetRecentSnapshot(20).Records, record => Assert.Equal(0, record.Metadata.CounterEpoch)); diff --git a/tests/CSharpDB.Tests/StorageRuntimeDiagnosticsTests.cs b/tests/CSharpDB.Tests/StorageRuntimeDiagnosticsTests.cs index 1a0c1bfa..c87aa13e 100644 --- a/tests/CSharpDB.Tests/StorageRuntimeDiagnosticsTests.cs +++ b/tests/CSharpDB.Tests/StorageRuntimeDiagnosticsTests.cs @@ -179,7 +179,7 @@ public async Task OpenSuccessPublication_SeesRegisteredStorageProvider() } [Fact] - public async Task DirectSummary_AggregatesTwoLiveBuiltInHandlesAndUnregistersThem() + public async Task DirectSummary_ReportsOneSharedOwnerAcrossOverlappingTransactions() { await using var client = new EngineTransportClient( $":memory:storage-runtime-{Guid.NewGuid():N}", @@ -262,8 +262,9 @@ await Database.OpenInMemoryAsync(options, ct), DiagnosticsTopologySnapshot afterClose = await diagnostics.GetRuntimeDiagnosticsAsync(Ct); Assert.Equal( - DiagnosticsAvailability.Unavailable, + DiagnosticsAvailability.Available, afterClose.Aggregate.Storage.Availability); + Assert.Equal(0, afterClose.Aggregate.Storage.Value!.ActiveWriters); Assert.Equal( DiagnosticsAvailability.Available, afterClose.Aggregate.Wal.Availability); @@ -1388,7 +1389,7 @@ public async Task MixedBuiltInAndCustomRegistrationsFailWholeFamilyUnsupported() } [Fact] - public async Task DirectSummary_MultipleRuntimeFamiliesKeepOnlyExactStorageAvailable() + public async Task DirectSummary_RetainedCaptureAndReplacementKeepOnlyExactStorageAvailable() { await using var client = new EngineTransportClient( $":memory:storage-runtime-families-{Guid.NewGuid():N}", @@ -1401,6 +1402,9 @@ await Database.OpenInMemoryAsync(options, ct), { transaction = await client.BeginTransactionAsync(Ct); Assert.Null((await client.ExecuteSqlAsync("SELECT 1", Ct)).Error); + using IDisposable retainedCapture = ClientDiagnosticsTestScope.RetainRuntimeFamily(client); + await client.RollbackTransactionAsync(transaction.TransactionId, Ct); + transaction = null; await client.ReleaseCachedDatabaseAsync(Ct); Assert.Null((await client.ExecuteSqlAsync("SELECT 2", Ct)).Error); @@ -1420,19 +1424,17 @@ await Database.OpenInMemoryAsync(options, ct), runtime.RuntimeFamilies) .ToArray(); Assert.Equal(2, families.Length); + Assert.Single(families, family => family.Value.Storage.Availability == DiagnosticsAvailability.Available); + Assert.Single(families, family => family.Value.Storage.Availability == DiagnosticsAvailability.Unavailable); Assert.All( families, family => { - Assert.Equal( - DiagnosticsAvailability.Available, - family.Value.Storage.Availability); Assert.Equal( DiagnosticsAvailability.Available, family.Value.Wal.Availability); - Assert.Equal( - family.Value.Metadata, - family.Value.Storage.Value!.Metadata); + if (family.Value.Storage.Value is { } storage) + Assert.Equal(family.Value.Metadata, storage.Metadata); Assert.Equal( family.Value.Metadata, family.Value.Wal.Value!.Metadata); diff --git a/tests/CSharpDB.Tests/TableArchiveJsonContractTests.cs b/tests/CSharpDB.Tests/TableArchiveJsonContractTests.cs new file mode 100644 index 00000000..b2413032 --- /dev/null +++ b/tests/CSharpDB.Tests/TableArchiveJsonContractTests.cs @@ -0,0 +1,143 @@ +using System.Buffers.Binary; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Nodes; +using CSharpDB.ImportExport.Models; +using CSharpDB.ImportExport.TableArchives; +using CSharpDB.Primitives; + +namespace CSharpDB.Tests; + +public sealed class TableArchiveJsonContractTests +{ + [Fact] + public async Task GeneratedMetadata_PreservesExistingSchemaAndManifestJsonBytes() + { + var ct = TestContext.Current.CancellationToken; + TableSchema schema = CreateSchema(); + await using var stream = new MemoryStream(); + TableArchiveManifest written = await TableArchiveWriter.WriteAsync( + stream, schema, TableArchiveWriter.ToAsyncRows([], ct), ct); + byte[] archive = stream.ToArray(); + // This is the pre-source-generation archive contract, kept independent + // of the product's generated context and converter registration. + var legacyOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false) }, + }; + Assert.Equal(JsonSerializer.SerializeToUtf8Bytes(TableArchiveSchema.FromTableSchema(schema), legacyOptions), + Section(archive, offsetField: 12, lengthField: 20)); + Assert.Equal(JsonSerializer.SerializeToUtf8Bytes(written, legacyOptions), + Section(archive, offsetField: 24, lengthField: 32)); + + stream.Position = 0; + var (readSchema, readManifest) = await TableArchiveReader.ReadMetadataAsync(stream, ct); + Assert.Equal(SqlTypeDescriptor.Create(SqlTypeKind.VarChar, length: 32), readSchema.Columns[1].DeclaredType); + Assert.Equal(SqlTypeKind.BigInt, readSchema.Columns[0].DeclaredType!.Kind); + Assert.Equal(ForeignKeyOnDeleteAction.Cascade, Assert.Single(readSchema.ForeignKeys).OnDelete); + Assert.Equal(KeyConstraintKind.PrimaryKey, Assert.Single(readSchema.KeyConstraints).Kind); + Assert.Equal(written.Digests!.Schema, readManifest.Digests!.Schema); + Assert.Equal(Section(archive, offsetField: 12, lengthField: 20), + JsonSerializer.SerializeToUtf8Bytes(readSchema, legacyOptions)); + Assert.Equal(Section(archive, offsetField: 24, lengthField: 32), + JsonSerializer.SerializeToUtf8Bytes(readManifest, legacyOptions)); + } + + [Theory] + [InlineData("type", "integer", (int)DbType.Integer)] + [InlineData("kind", "bigInt", (int)SqlTypeKind.BigInt)] + [InlineData("onDelete", "cascade", (int)ForeignKeyOnDeleteAction.Cascade)] + [InlineData("kind", "primaryKey", (int)KeyConstraintKind.PrimaryKey)] + public async Task GeneratedMetadata_StillRejectsNumericEnums(string property, string name, int numeric) + { + var ct = TestContext.Current.CancellationToken; + await using var stream = new MemoryStream(); + await TableArchiveWriter.WriteAsync(stream, CreateSchema(), TableArchiveWriter.ToAsyncRows([], ct), ct); + byte[] archive = stream.ToArray(); + byte[] original = Encoding.UTF8.GetBytes($"\"{property}\": \"{name}\""); + byte[] replacement = Encoding.UTF8.GetBytes($"\"{property}\": {numeric}".PadRight(original.Length)); + int offset = archive.AsSpan().IndexOf(original); + Assert.True(offset >= 76); + Assert.Equal(original.Length, replacement.Length); + replacement.CopyTo(archive, offset); + await using var modified = new MemoryStream(archive); + // Deserialization precedes digest validation. A converter that accepts + // numeric enums would reach the digest failure instead of JsonException. + await Assert.ThrowsAsync(async () => await TableArchiveReader.ReadMetadataAsync(modified, ct)); + } + + private static byte[] Section(byte[] archive, int offsetField, int lengthField) + { + int offset = checked((int)BinaryPrimitives.ReadInt64LittleEndian(archive.AsSpan(offsetField))); + int length = BinaryPrimitives.ReadInt32LittleEndian(archive.AsSpan(lengthField)); + return archive.AsSpan(offset, length).ToArray(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task MissingManifestFields_KeepDefaultsWithoutAcceptingExplicitNull(bool indexed) + { + var ct = TestContext.Current.CancellationToken; + var schema = new TableSchema + { + TableName = "manifest_defaults", + Columns = [new ColumnDefinition { Name = "id", Type = DbType.Integer, DeclaredType = SqlTypeDescriptor.Create(SqlTypeKind.BigInt), IsPrimaryKey = indexed, Nullable = false }], + }; + await using var stream = new MemoryStream(); + await TableArchiveWriter.WriteAsync(stream, schema, + TableArchiveWriter.ToAsyncRows([new[] { DbValue.FromInteger(1) }], ct), ct); + byte[] archive = stream.ToArray(); + JsonObject manifest = JsonNode.Parse(Section(archive, 24, 32))!.AsObject(); + foreach (string name in new[] { "formatVersion", "createdUtc", "schemaEntry", "rowsEntry" }) manifest.Remove(name); + if (indexed) + foreach (JsonNode? index in manifest["indexes"]!.AsArray()) index!.AsObject().Remove("sectionEntry"); + else manifest.Remove("indexes"); + manifest["digests"]!.AsObject().Remove("algorithm"); + manifest["digests"]!.AsObject().Remove("encoding"); + + byte[] RewriteManifest() + { + int offset = checked((int)BinaryPrimitives.ReadInt64LittleEndian(archive.AsSpan(24))); + byte[] json = JsonSerializer.SerializeToUtf8Bytes(manifest); + byte[] result = new byte[offset + json.Length]; + archive.AsSpan(0, offset).CopyTo(result); + json.CopyTo(result, offset); + BinaryPrimitives.WriteInt32LittleEndian(result.AsSpan(32), json.Length); + return result; + } + DateTimeOffset before = DateTimeOffset.UtcNow; + await using var omitted = new MemoryStream(RewriteManifest()); + var (_, restored) = await TableArchiveReader.ReadMetadataAsync(omitted, ct); + Assert.Equal(TableArchiveManifest.LatestFormatVersion, restored.FormatVersion); + Assert.InRange(restored.CreatedUtc, before, DateTimeOffset.UtcNow); + Assert.Equal("native:schema", restored.SchemaEntry); + Assert.Equal("native:rows", restored.RowsEntry); + Assert.Equal(TableArchiveSectionDigests.Sha256Algorithm, restored.Digests!.Algorithm); + Assert.Equal(TableArchiveSectionDigests.LowercaseHexEncoding, restored.Digests.Encoding); + if (indexed) Assert.Equal("native:index:primary-key", Assert.Single(restored.Indexes).SectionEntry); + else Assert.Empty(restored.Indexes); + + manifest["digests"]!["algorithm"] = null; + await using var explicitNull = new MemoryStream(RewriteManifest()); + await Assert.ThrowsAsync(async () => await TableArchiveReader.ReadMetadataAsync(explicitNull, ct)); + } + + private static TableSchema CreateSchema() => new() + { + TableName = "json_contract", + Columns = + [ + new ColumnDefinition { Name = "id", Type = DbType.Integer, DeclaredType = SqlTypeDescriptor.Create(SqlTypeKind.BigInt), IsPrimaryKey = true, Nullable = false }, + new ColumnDefinition { Name = "name", Type = DbType.Text, DeclaredType = SqlTypeDescriptor.Create(SqlTypeKind.VarChar, length: 32), Nullable = true }, + ], + ForeignKeys = + [ + new ForeignKeyDefinition { ConstraintName = "fk_contract", ColumnName = "id", ReferencedTableName = "parent", ReferencedColumnName = "id", SupportingIndexName = "fk_contract_index", OnDelete = ForeignKeyOnDeleteAction.Cascade }, + ], + KeyConstraints = [new KeyConstraintDefinition { Kind = KeyConstraintKind.PrimaryKey, Columns = ["id"] }], + }; +} diff --git a/tests/CSharpDB.Tests/WriteTransactionDisposalTests.cs b/tests/CSharpDB.Tests/WriteTransactionDisposalTests.cs new file mode 100644 index 00000000..434853de --- /dev/null +++ b/tests/CSharpDB.Tests/WriteTransactionDisposalTests.cs @@ -0,0 +1,105 @@ +using CSharpDB.Engine; +using CSharpDB.Storage.Indexing; +using CSharpDB.Storage.Paging; +using CSharpDB.Storage.StorageEngine; + +namespace CSharpDB.Tests; + +public sealed class WriteTransactionDisposalTests +{ + [Fact] + public async Task CommitPreparationFailure_DisposeRollsBackAndReleasesSnapshot() + { + CancellationToken ct = TestContext.Current.CancellationToken; + var indexes = new OneShotRootFailureIndexProvider(); + await using var database = await Database.OpenInMemoryAsync(new DatabaseOptions + { + StorageEngineOptions = new StorageEngineOptions { IndexProvider = indexes }, + }, ct); + await database.ExecuteAsync( + "CREATE TABLE preparation_items (id INTEGER PRIMARY KEY, value INTEGER)", ct); + await database.ExecuteAsync( + "CREATE INDEX ix_preparation_value ON preparation_items (value)", ct); + await database.ExecuteAsync("INSERT INTO preparation_items VALUES (1, 10)", ct); + + WriteTransaction transaction = await database.BeginWriteTransactionAsync(ct); + try + { + await using var insert = await transaction.ExecuteAsync( + "INSERT INTO preparation_items VALUES (2, 20)", ct); + Assert.Equal(1, database.ActiveReaderCount); + + // An index provider can fail while the catalog prepares its root + // metadata, before any WAL commit publication begins. + indexes.Arm(); + IOException failure = await Assert.ThrowsAsync( + () => transaction.CommitAsync(ct).AsTask()); + Assert.Same(indexes.Failure, failure); + Assert.Equal(1, indexes.InjectedFailures); + } + finally + { + await transaction.DisposeAsync(); + } + + Assert.Equal(0, database.ActiveReaderCount); + await using (var result = await database.ExecuteAsync( + "SELECT id FROM preparation_items ORDER BY id", ct)) + { + Assert.Equal(1L, Assert.Single(await result.ToListAsync(ct))[0].AsInteger); + } + + await using (WriteTransaction next = await database.BeginWriteTransactionAsync(ct)) + { + await using var insert = await next.ExecuteAsync( + "INSERT INTO preparation_items VALUES (3, 30)", ct); + await next.CommitAsync(ct); + } + Assert.Equal(0, database.ActiveReaderCount); + await using var verified = await database.ExecuteAsync( + "SELECT id FROM preparation_items ORDER BY id", ct); + Assert.Equal(new long[] { 1, 3 }, + (await verified.ToListAsync(ct)).Select(row => row[0].AsInteger)); + } + + private sealed class OneShotRootFailureIndexProvider : IIndexProvider + { + private readonly BTreeIndexProvider _inner = new(); + private int _armed; + private int _injectedFailures; + + public IOException Failure { get; } = new("Injected index root metadata failure."); + public int InjectedFailures => Volatile.Read(ref _injectedFailures); + public void Arm() => Volatile.Write(ref _armed, 1); + + public IIndexStore CreateIndexStore(Pager pager, uint rootPageId, string logicalName) + => new FaultingIndexStore(_inner.CreateIndexStore(pager, rootPageId, logicalName), this); + + private void ReadRoot() + { + if (Interlocked.Exchange(ref _armed, 0) == 0) return; + Interlocked.Increment(ref _injectedFailures); + throw Failure; + } + + private sealed class FaultingIndexStore(IIndexStore inner, OneShotRootFailureIndexProvider owner) + : IIndexStore + { + public string LogicalName => inner.LogicalName; + public uint RootPageId { get { owner.ReadRoot(); return inner.RootPageId; } } + public void RecordPointRead(long key) => inner.RecordPointRead(key); + public void RecordRangeRead(IndexScanRange range) => inner.RecordRangeRead(range); + public ValueTask FindAsync(long key, CancellationToken ct = default) + => inner.FindAsync(key, ct); + public ValueTask FindMaxKeyAsync(IndexScanRange range, CancellationToken ct = default) + => inner.FindMaxKeyAsync(range, ct); + public ValueTask InsertAsync(long key, ReadOnlyMemory payload, CancellationToken ct = default) + => inner.InsertAsync(key, payload, ct); + public ValueTask ReplaceAsync(long key, ReadOnlyMemory payload, CancellationToken ct = default) + => inner.ReplaceAsync(key, payload, ct); + public ValueTask DeleteAsync(long key, CancellationToken ct = default) + => inner.DeleteAsync(key, ct); + public IIndexCursor CreateCursor(IndexScanRange range) => inner.CreateCursor(range); + } + } +} diff --git a/tests/Shared/GrpcSocketDaemonFixture.cs b/tests/Shared/GrpcSocketDaemonFixture.cs new file mode 100644 index 00000000..94af4f80 --- /dev/null +++ b/tests/Shared/GrpcSocketDaemonFixture.cs @@ -0,0 +1,169 @@ +using System.Collections.Concurrent; +using System.Net; +using CSharpDB.Client; +using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace CSharpDB.Testing; + +/// Runs the actual daemon with two real loopback socket listeners. +internal sealed class GrpcSocketDaemonFixture : IAsyncDisposable +{ + private readonly SocketDaemonFactory _factory; + private readonly string _dbPath; + private readonly ProtocolCapture _capture = new(); + + public GrpcSocketDaemonFixture(string? apiKey = null, Action? configureServices = null) + { + _dbPath = Path.Combine(Path.GetTempPath(), $"csharpdb_grpc_socket_{Guid.NewGuid():N}.db"); + _factory = new SocketDaemonFactory(_dbPath, _capture, apiKey, configureServices); + _factory.UseKestrel(options => + { + options.Listen(IPAddress.Loopback, 0, listener => ConfigureListener(listener, HttpProtocols.Http1)); + options.Listen(IPAddress.Loopback, 0, listener => ConfigureListener(listener, HttpProtocols.Http2)); + }); + try + { + string[] addresses = _factory.Services.GetRequiredService().Features + .Get()!.Addresses.ToArray(); + if (addresses.Length != 2) + throw new InvalidOperationException("Expected exactly two loopback listeners."); + CompatibilityEndpoint = new Uri(addresses[0]); + NativeEndpoint = new Uri(addresses[1]); + } + catch + { + _factory.Dispose(); + DeleteDatabaseFiles(); + throw; + } + } + + public Uri CompatibilityEndpoint { get; } + public Uri NativeEndpoint { get; } + public IReadOnlyDictionary Snapshot() => _capture.Counts.ToDictionary(); + public IReadOnlyList ConnectionClosures => _capture.ConnectionClosures.Values.ToArray(); + + private void ConfigureListener(ListenOptions listener, HttpProtocols protocol) + { + listener.Protocols = protocol; + listener.Use(next => async connection => + { + var closed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _capture.ConnectionClosures.TryAdd(connection.ConnectionId, closed.Task); + using var registration = connection.ConnectionClosed.Register(() => closed.TrySetResult()); + try { await next(connection); } + finally { closed.TrySetResult(); } + }); + } + + public ICSharpDbClient CreateClient(CSharpDbGrpcMode mode, string? apiKey = null) + => CSharpDbClient.Create(new CSharpDbClientOptions + { + Transport = CSharpDbTransport.Grpc, + GrpcMode = mode, + Endpoint = EndpointFor(mode).AbsoluteUri, + ApiKey = apiKey, + }); + + public Uri EndpointFor(CSharpDbGrpcMode mode) + => mode == CSharpDbGrpcMode.Native ? NativeEndpoint : CompatibilityEndpoint; + + public async ValueTask DisposeAsync() + { + await _factory.DisposeAsync(); + DeleteDatabaseFiles(); + } + + private void DeleteDatabaseFiles() + { + foreach (string path in new[] { _dbPath, _dbPath + ".wal" }) + { + try { File.Delete(path); } + catch (IOException) { } + } + } + + internal readonly record struct RequestShape(string Protocol, string ContentType, string Method); + + private sealed class ProtocolCapture : IStartupFilter + { + public ConcurrentDictionary Counts { get; } = new(); + public ConcurrentDictionary ConnectionClosures { get; } = new(); + + public Action Configure(Action next) + => app => + { + app.Use(async (context, continuation) => + { + if (context.Request.Path.StartsWithSegments("/csharpdb.rpc.CSharpDbRpc")) + { + var shape = new RequestShape(context.Request.Protocol, + context.Request.ContentType ?? "", context.Request.Path.Value!); + Counts.AddOrUpdate(shape, 1, static (_, count) => count + 1); + } + await continuation(); + }); + next(app); + }; + } + + private sealed class SocketDaemonFactory(string dbPath, ProtocolCapture capture, string? apiKey, Action? configureServices) + : WebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + // Standalone benchmark executables do not receive the test SDK's + // generated content-root metadata. Resolve the actual source host + // instead of the factory's solution-root/assembly-name fallback. + builder.UseContentRoot(FindDaemonContentRoot()); + builder.UseEnvironment("Development"); + var settings = new Dictionary + { + ["ConnectionStrings:CSharpDB"] = $"Data Source={dbPath}", + ["CSharpDB:Observability:Enabled"] = "false", + ["CSharpDB:Observability:Health:Enabled"] = "false", + ["CSharpDB:Daemon:Security:Mode"] = apiKey is null ? "None" : "ApiKey", + ["CSharpDB:Daemon:Security:ApiKey"] = apiKey, + }; + foreach (var pair in settings) + if (pair.Value is not null) + builder.UseSetting(pair.Key, pair.Value); + builder.ConfigureAppConfiguration((_, config) => config.AddInMemoryCollection(settings)); + builder.ConfigureLogging(logging => logging.ClearProviders()); + builder.ConfigureServices(services => + { + services.AddSingleton(capture); + services.AddHostedService(); + configureServices?.Invoke(services); + }); + } + + private static string FindDaemonContentRoot() + { + for (DirectoryInfo? directory = new(AppContext.BaseDirectory); + directory is not null; directory = directory.Parent) + { + string candidate = Path.Combine(directory.FullName, "src", "CSharpDB.Daemon"); + if (File.Exists(Path.Combine(candidate, "CSharpDB.Daemon.csproj"))) + return candidate; + } + throw new DirectoryNotFoundException("Run the socket fixture from a CSharpDB source checkout."); + } + } + + private sealed class ClientShutdown(ICSharpDbClient client) : IHostedService + { + public Task StartAsync(CancellationToken ct) => Task.CompletedTask; + public Task StopAsync(CancellationToken ct) => client.DisposeAsync().AsTask(); + } +} diff --git a/www/changelog.html b/www/changelog.html index 4cf0b2c0..e0a99108 100644 --- a/www/changelog.html +++ b/www/changelog.html @@ -79,10 +79,15 @@

Changelog

Earlier releases All GitHub releases ↗ -
+
UnreleasedDevelopment work · not in a published package
-

Upcoming changes

-

Future development updates will be listed here.

+

Shared gRPC sessions and native archive fixes

+
    +
  • Remote clients: share bounded SDK and ADO.NET connection pools, prevent queued transaction starts from blocking commit or rollback, preserve transaction and result ownership, and add explicit native gRPC selection with experimental persistent sessions.
  • +
  • Native archives: fix archive-backed SQL with generated JSON metadata and add published NativeAOT checks for scans, lookups, declared types, and reopen persistence.
  • +
  • Maintenance: support private migration reject reports on Linux tmpfs, update the optional SQL Server migration dependency, and include all 27 test modules in pull-request CI.
  • +
+

Unary calls remain the default. Streaming is experimental and opt-in. No verified network speedup is claimed; the custom TCP prototype is excluded.

Published releases