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
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