From 7dbe9845db7b8048f0d7262dc261ae13f6b5d4c5 Mon Sep 17 00:00:00 2001 From: Guian Gumpac Date: Thu, 18 Jun 2026 18:36:25 -0700 Subject: [PATCH 1/4] Standardized `gremlin-dotnet` connection options Assisted-by: Kiro: Claude Opus 4.8 --- CHANGELOG.asciidoc | 12 + docs/src/reference/gremlin-variants.asciidoc | 29 +- docs/src/upgrade/release-4.x.x.asciidoc | 39 +++ .../Examples/Connections/Connections.cs | 4 +- gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs | 30 +- .../src/Gremlin.Net/Driver/Compression.cs | 107 +++++++ .../src/Gremlin.Net/Driver/Connection.cs | 211 ++++++++++++- .../Gremlin.Net/Driver/ConnectionSettings.cs | 150 ++++++++- .../src/Gremlin.Net/Driver/GremlinServer.cs | 64 ++++ .../Driver/Messages/RequestMessage.cs | 15 + .../Gremlin.Net/Driver/ReadTimeoutStream.cs | 111 +++++++ .../Driver/StreamingResponseContext.cs | 12 +- .../CompressionBenchmarks.cs | 4 +- .../Docs/Reference/GremlinVariantsTests.cs | 2 +- .../Driver/AuthIntegrationTests.cs | 6 +- .../Gremlin.Net.UnitTest/Driver/AuthTests.cs | 60 +++- .../Driver/ConnectionSettingsTests.cs | 165 ++++++++++ .../Driver/ConnectionTests.cs | 290 +++++++++++++++++- .../Driver/GremlinClientTests.cs | 2 +- .../Driver/GremlinServerTests.cs | 136 ++++++++ 20 files changed, 1370 insertions(+), 79 deletions(-) create mode 100644 gremlin-dotnet/src/Gremlin.Net/Driver/Compression.cs create mode 100644 gremlin-dotnet/src/Gremlin.Net/Driver/ReadTimeoutStream.cs create mode 100644 gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionSettingsTests.cs diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc index a1e797ffe37..5db52cdc165 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -25,6 +25,18 @@ image::https://raw.githubusercontent.com/apache/tinkerpop/master/docs/static/ima [[release-4-0-0]] === TinkerPop 4.0.0 (Release Date: NOT OFFICIALLY RELEASED YET) +* Standardized `gremlin-dotnet` connection options per the TinkerPop 4.x GLV proposal: +** Renamed the `Auth.BasicAuth`/`Auth.SigV4Auth` factory methods to `Auth.Basic`/`Auth.Sigv4`; the old methods are retained as `[Obsolete]` deprecated aliases that delegate to the canonical members, so existing code keeps compiling. +** Renamed `MaxConnectionsPerServer` to `MaxConnections`, `IdleConnectionTimeout` to `IdleTimeout`, and `KeepAliveInterval` to `KeepAliveTime` (now wired to a real TCP keep-alive socket option rather than the inert HTTP/2 ping timeout, enabling `SO_KEEPALIVE` and setting the per-socket idle time on Windows, Linux, and macOS, with a no-op fallback to the OS default idle time on other platforms); the old property names are retained as `[Obsolete]` deprecated aliases. +** Renamed `ConnectionTimeout` to `ConnectTimeout` (default lowered from 15s to 5s; old name retained as a deprecated alias). *(breaking)* +** Renamed `EnableCompression` to `Compression`, now a `{None, Deflate}` enum defaulting to `Deflate` (compression on by default; set `None` to disable). The deprecated `EnableCompression` `bool` is retained as a compatibility shim mapping `true`/`false` to `Compression.Deflate`/`Compression.None`. *(breaking)* +** Added `Ssl` (an `SslClientAuthenticationOptions`; `SkipCertificateValidation` is applied to an internal copy rather than mutating the caller's options). +** Added `DefaultBatchSize` (default 64), a connection-level default that fills the per-request `batchSize` when unset. +** Added `MaxResponseHeaderBytes`, exposing the handler's maximum response header size. +** Added `ReadTimeout`, a per-read idle timeout applied to each read of the response stream. +** Added `Proxy`, routing connections through an `IWebProxy`. +* Fixed `gremlin-dotnet` deflate response decompression, which threw on the server's zlib-framed output because it used `DeflateStream` (raw DEFLATE, RFC 1951) instead of `ZLibStream` (zlib, RFC 1950); the bug was previously masked because compression was off by default. +* Fixed `gremlin-dotnet` SSL options cloning (used on the skip-certificate-validation path) to copy `ClientCertificateContext` and `AllowTlsResume`, which were previously dropped, breaking mTLS client certificates and silently re-enabling TLS resumption. * Standardized `gremlin-python` connection options per the TinkerPop 4.x GLV proposal: ** Renamed `pool_size` to `max_connections` (breaking; the old name has been removed) and changed the default from 8 to 128; `max_connections` is now also applied to the aiohttp `TCPConnector` `limit` so the transport layer reflects the option in addition to sizing the Connection pool. ** Renamed `ssl_options` to `ssl` accepting an `ssl.SSLContext` (breaking; the old name has been removed). diff --git a/docs/src/reference/gremlin-variants.asciidoc b/docs/src/reference/gremlin-variants.asciidoc index eb3e324a98f..df51df3570e 100644 --- a/docs/src/reference/gremlin-variants.asciidoc +++ b/docs/src/reference/gremlin-variants.asciidoc @@ -2589,7 +2589,7 @@ include::../../../gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Docs/Reference Authentication is handled through request interceptors. Interceptors are functions that modify the outgoing HTTP request before it is sent — they are used for authentication, custom headers, or request signing. The `Auth` class -provides `BasicAuth()` and `SigV4Auth()` as built-in interceptors: +provides `Basic()` and `SigV4()` as built-in interceptors: [source,csharp] ---- @@ -2597,12 +2597,12 @@ provides `BasicAuth()` and `SigV4Auth()` as built-in interceptors: var server = new GremlinServer("localhost", 8182, enableSsl: true); using var client = new GremlinClient(server, connectionSettings: new ConnectionSettings { SkipCertificateValidation = true }, - interceptors: new[] { Auth.BasicAuth("username", "password") }); + interceptors: new[] { Auth.Basic("username", "password") }); // SigV4 authentication var server = new GremlinServer("localhost", 8182, enableSsl: true); using var client = new GremlinClient(server, - interceptors: new[] { Auth.SigV4Auth("service-region", "service-name") }); + interceptors: new[] { Auth.Sigv4("service-region", "service-name") }); ---- If you authenticate to a remote <> or @@ -2658,16 +2658,21 @@ constructor: [width="100%",cols="3,10,^2",options="header"] |========================================================= |Key |Description |Default -|ConnectionTimeout |The TCP connection timeout. |15 s -|IdleConnectionTimeout |How long idle connections stay in the pool before being closed. |180 s -|MaxConnectionsPerServer |The maximum concurrent connections to a single server. |128 -|KeepAliveInterval |The TCP keep-alive probe interval. |30 s -|EnableCompression |Whether to request deflate compression. |false +|ConnectTimeout |The TCP connect timeout (transport establishment, i.e. TCP connect plus TLS handshake where applicable, not an HTTP request timeout). |5 s +|IdleTimeout |How long idle connections stay in the pool before being closed. |180 s +|MaxConnections |The maximum concurrent connections to a single server. |128 +|KeepAliveTime |Idle time before TCP keep-alive probes begin on an otherwise idle connection. Enables `SO_KEEPALIVE` on the socket and sets the per-socket idle time on Windows, Linux, and macOS; on other platforms keep-alive stays enabled at the OS default idle time. |30 s +|Compression |The response compression algorithm (`Compression.None` or `Compression.Deflate`). |`Compression.Deflate` +|DefaultBatchSize |The default batch size used to fill the per-request batch size when it is unset. |64 +|Ssl |The `SslClientAuthenticationOptions` used for HTTPS connections (client certificates, custom CA, protocols, etc.). `SkipCertificateValidation` is applied to an internal copy of these options rather than mutating the object you provide. |`null` +|MaxResponseHeaderBytes |The maximum allowed size, in bytes, of the response headers. `0` leaves the handler default unchanged (converted internally to the handler's native kilobyte unit). |0 +|ReadTimeout |The idle-read timeout applied to each individual read of the response stream. It resets per chunk. `Timeout.InfiniteTimeSpan` disables it. |`Timeout.InfiniteTimeSpan` +|Proxy |The `IWebProxy` used for connections. |`null` |EnableUserAgentOnConnect |Enables sending a user agent to the server on requests. More details can be found in provider docs link:https://tinkerpop.apache.org/docs/x.y.z/dev/provider/#_graph_driver_provider_requirements[here].|true |BulkResults |Whether to send the bulkResults header on all requests. |false -|SkipCertificateValidation |Whether to skip SSL certificate validation. Only use for testing with self-signed certificates. |false +|SkipCertificateValidation |Whether to skip SSL certificate validation. Only use for testing with self-signed certificates. When `Ssl` is also provided, the accept-all callback is set on an internal copy so the supplied options object is never mutated. |false |========================================================= ==== GremlinClient Settings @@ -2699,7 +2704,7 @@ A request interceptor is a `Func` that mutates the `Ht `Task` for async support but does not produce a value). A list of these is maintained and will be run sequentially for each request. When creating a `GremlinClient`, the `interceptors` parameter accepts an ordered collection of interceptors. Order matters, so if one interceptor depends on another's output, ensure they are added in the correct -order. Note that authentication (e.g. `Auth.BasicAuth()`, `Auth.SigV4Auth()`) is also implemented using interceptors. +order. Note that authentication (e.g. `Auth.Basic()`, `Auth.Sigv4()`) is also implemented using interceptors. These factory methods return interceptor delegates that can be included in the `interceptors` list. Alternatively, the `auth` parameter on `GremlinClient` appends the auth interceptor to the end of the list so it runs last. @@ -2709,7 +2714,7 @@ var server = new GremlinServer("localhost", 8182); using var client = new GremlinClient(server, interceptors: new Func[] { - Auth.BasicAuth("username", "password"), + Auth.Basic("username", "password"), context => { context.Headers["X-Custom-Header"] = "value"; @@ -2881,7 +2886,7 @@ order and are useful for authentication, custom headers, or request signing. [source,csharp] ---- var client = new GremlinClient(new GremlinServer("localhost", 8182), - interceptors: new[] { Auth.BasicAuth("username", "password") }); + interceptors: new[] { Auth.Basic("username", "password") }); ---- When `requestSerializer` is set to `null`, the request body is passed as a `RequestMessage` to interceptors, and an diff --git a/docs/src/upgrade/release-4.x.x.asciidoc b/docs/src/upgrade/release-4.x.x.asciidoc index 15c63c84000..e991ca614e8 100644 --- a/docs/src/upgrade/release-4.x.x.asciidoc +++ b/docs/src/upgrade/release-4.x.x.asciidoc @@ -32,6 +32,45 @@ complete list of all the modifications that are part of this release. === Upgrading for Users +==== Standardizing .NET Connection Options + +TinkerPop 4.x standardizes connection option names and defaults across the GLVs. In `gremlin-dotnet`, several +`ConnectionSettings` properties and the `Auth` factory methods have been renamed for consistency, with the old names +retained as `[Obsolete]` deprecated aliases so that existing code keeps compiling, and a number of new options have +been added. The notes below describe the .NET changes. See <> for the +equivalent changes in the other drivers. + +Renames (deprecated aliases). The following members have been renamed. The old names still work but are deprecated +and should be migrated to the new names: + +- `ConnectionTimeout` is now `ConnectTimeout`. +- `MaxConnectionsPerServer` is now `MaxConnections`. +- `IdleConnectionTimeout` is now `IdleTimeout`. +- `KeepAliveInterval` is now `KeepAliveTime`. +- `EnableCompression` is now `Compression`. +- The `Auth.BasicAuth` and `Auth.SigV4Auth` factory methods are now `Auth.Basic` and `Auth.Sigv4`. + +Behavior changes. These change runtime behavior on upgrade, even if you do not change your configuration: + +- `ConnectTimeout` now defaults to 5s (lowered from 15s). +- `KeepAliveTime` is now wired to a real TCP keep-alive socket option rather than the inert HTTP/2 ping timeout. It +enables `SO_KEEPALIVE` and sets the per-socket idle time on Windows, Linux, and macOS; on other platforms keep-alive +stays enabled at the OS default idle time. +- `Compression` is now a `{None, Deflate}` enum defaulting to `Deflate` (compression on by default), so the driver +sends `Accept-Encoding: deflate` by default. Set `Compression.None` to disable. The deprecated `EnableCompression` +`bool` is preserved only as a compatibility shim mapping `true`/`false` to `Compression.Deflate`/`Compression.None`. + +New options: + +- `Ssl` (an `SslClientAuthenticationOptions` for client certificates and custom CAs; `SkipCertificateValidation` is +applied to an internal copy rather than mutating the supplied options). +- `DefaultBatchSize` (default 64): a connection-level default that fills the per-request batch size when unset. +- `MaxResponseHeaderBytes`: the maximum allowed size, in bytes, of the response headers. +- `ReadTimeout`: a per-read idle timeout applied to each individual read of the response stream. +- `Proxy`: routes connections through an `IWebProxy`. + +See: link:https://lists.apache.org/thread/yqtr2wnb1kq2pqqq4002cz511q5o0bkg[dev list discussion on standardizing GLV connection options]. + ==== Standardizing Go Connection Options TinkerPop 4.x standardizes connection option names and defaults across the GLVs. In `gremlin-go`, several diff --git a/gremlin-dotnet/Examples/Connections/Connections.cs b/gremlin-dotnet/Examples/Connections/Connections.cs index bcfda7e4f8f..8e7354c2012 100644 --- a/gremlin-dotnet/Examples/Connections/Connections.cs +++ b/gremlin-dotnet/Examples/Connections/Connections.cs @@ -54,7 +54,7 @@ static void WithConf() var server = new GremlinServer(ServerHost, ServerPort); var settings = new ConnectionSettings { - ConnectionTimeout = TimeSpan.FromSeconds(30), + ConnectTimeout = TimeSpan.FromSeconds(30), }; using var remoteConnection = new DriverRemoteConnection( new GremlinClient(server, connectionSettings: settings), "g"); @@ -71,7 +71,7 @@ static void WithBasicAuth() var server = new GremlinServer(ServerHost, SecureServerPort, enableSsl: true); var client = new GremlinClient(server, connectionSettings: new ConnectionSettings { SkipCertificateValidation = true }, - interceptors: new[] { Auth.BasicAuth("stephen", "password") }); + interceptors: new[] { Auth.Basic("stephen", "password") }); using var remoteConnection = new DriverRemoteConnection(client, "g"); var g = Traversal().With(remoteConnection); diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs index eaf706f6dda..0e842465faf 100644 --- a/gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs +++ b/gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs @@ -43,7 +43,7 @@ public static class Auth /// The username. /// The password. /// A request interceptor delegate. - public static Func BasicAuth(string username, string password) + public static Func Basic(string username, string password) { var encoded = Convert.ToBase64String( Encoding.UTF8.GetBytes(username + ":" + password)); @@ -68,7 +68,7 @@ public static Func BasicAuth(string username, string p /// Optional AWS credentials. When null, the default credential chain is used. /// /// A request interceptor delegate. - public static Func SigV4Auth( + public static Func Sigv4( string region, string service, AWSCredentials? credentials = null) { // Cache the credential provider once when using the default chain. @@ -105,6 +105,32 @@ public static Func SigV4Auth( }; } + /// + /// Returns a request interceptor that adds an HTTP Basic Authentication header. + /// + /// The username. + /// The password. + /// A request interceptor delegate. + /// Deprecated compatibility alias; delegates to . + [Obsolete("As of release 4.0.0, replaced by Basic.")] + public static Func BasicAuth(string username, string password) => + Basic(username, password); + + /// + /// Returns a request interceptor that signs requests using AWS Signature Version 4. + /// + /// The AWS region (e.g. "us-east-1"). + /// The AWS service name (e.g. "neptune-db"). + /// + /// Optional AWS credentials. When null, the default credential chain is used. + /// + /// A request interceptor delegate. + /// Deprecated compatibility alias; delegates to . + [Obsolete("As of release 4.0.0, replaced by Sigv4.")] + public static Func SigV4Auth( + string region, string service, AWSCredentials? credentials = null) => + Sigv4(region, service, credentials); + private static void SignRequest(HttpRequestContext context, ImmutableCredentials credentials, AWS4Signer signer, SigningClientConfig clientConfig) { diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/Compression.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/Compression.cs new file mode 100644 index 00000000000..15bc0e58533 --- /dev/null +++ b/gremlin-dotnet/src/Gremlin.Net/Driver/Compression.cs @@ -0,0 +1,107 @@ +#region License + +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#endregion + +using System; + +namespace Gremlin.Net.Driver +{ + /// + /// The compression algorithm requested for server responses. The server currently + /// supports only; additional members are reserved for when the + /// server adds support for them (server-side first). + /// + public enum CompressionType + { + /// + /// No compression. + /// + None, + + /// + /// Deflate compression. + /// + Deflate + } + + /// + /// Configures response compression. A is implicitly + /// convertible ( = , + /// = ). + /// + public readonly struct Compression : IEquatable + { + /// + /// Gets the configured compression algorithm. + /// + public CompressionType Type { get; } + + /// + /// Initializes a new instance of the struct. + /// + /// The compression algorithm. + public Compression(CompressionType type) + { + Type = type; + } + + /// + /// No compression. + /// + public static Compression None => new Compression(CompressionType.None); + + /// + /// Deflate compression. + /// + public static Compression Deflate => new Compression(CompressionType.Deflate); + + /// + /// Gets whether compression is enabled (i.e. the algorithm is not ). + /// + public bool Enabled => Type != CompressionType.None; + + /// + /// Implicitly converts a to a . + /// + /// The compression algorithm. + public static implicit operator Compression(CompressionType type) => + new Compression(type); + + /// + public bool Equals(Compression other) => Type == other.Type; + + /// + public override bool Equals(object? obj) => obj is Compression other && Equals(other); + + /// + public override int GetHashCode() => (int)Type; + + /// Determines whether two values are equal. + public static bool operator ==(Compression left, Compression right) => left.Equals(right); + + /// Determines whether two values are not equal. + public static bool operator !=(Compression left, Compression right) => !left.Equals(right); + + /// + public override string ToString() => Type.ToString(); + } +} diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs index 82a0d17724a..9b272d8fe23 100644 --- a/gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs +++ b/gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs @@ -67,18 +67,125 @@ public Connection(Uri uri, var handler = new SocketsHttpHandler { - PooledConnectionIdleTimeout = settings.IdleConnectionTimeout, - MaxConnectionsPerServer = settings.MaxConnectionsPerServer, - ConnectTimeout = settings.ConnectionTimeout, - KeepAlivePingTimeout = settings.KeepAliveInterval, + PooledConnectionIdleTimeout = settings.IdleTimeout, + MaxConnectionsPerServer = settings.MaxConnections, + ConnectTimeout = settings.ConnectTimeout, }; - if (settings.SkipCertificateValidation) + + // Rewire keep-alive to a real TCP socket option (HTTP/1.1). The handler's + // KeepAlivePingTimeout only applies to HTTP/2; instead open the socket ourselves + // in a ConnectCallback and set the TCP keep-alive idle time. Probe interval and + // count stay at OS defaults (not standardized). + var keepAliveTime = settings.KeepAliveTime; + handler.ConnectCallback = async (context, cancellationToken) => + { + // Resolve the endpoint to concrete IP addresses and attempt each with its own + // socket. A single Socket cannot be reused across connection attempts (handing a + // multi-address DnsEndPoint to one socket throws "Sockets on this platform are + // invalid for use after a failed connection attempt"), so a fresh socket per + // address is required to support round-robin/fallback DNS. + var endpoint = context.DnsEndPoint; + System.Net.IPAddress[] addresses; + if (System.Net.IPAddress.TryParse(endpoint.Host, out var literal)) + { + addresses = new[] { literal }; + } + else + { + addresses = await System.Net.Dns.GetHostAddressesAsync( + endpoint.Host, cancellationToken).ConfigureAwait(false); + } + + if (addresses.Length == 0) + { + throw new System.Net.Sockets.SocketException( + (int)System.Net.Sockets.SocketError.HostNotFound); + } + + System.Exception? lastError = null; + foreach (var address in addresses) + { + var socket = new System.Net.Sockets.Socket( + address.AddressFamily, + System.Net.Sockets.SocketType.Stream, + System.Net.Sockets.ProtocolType.Tcp) + { + NoDelay = true + }; + try + { + socket.SetSocketOption(System.Net.Sockets.SocketOptionLevel.Socket, + System.Net.Sockets.SocketOptionName.KeepAlive, true); + var keepAliveSeconds = (int)keepAliveTime.TotalSeconds; + if (keepAliveSeconds > 0) + { + // Set the idle time before the first keep-alive probe. Windows/Linux use + // the TcpKeepAliveTime enum; macOS uses the equivalent raw TCP_KEEPALIVE + // option. Other platforms keep the OS default idle time. + if (OperatingSystem.IsWindows() || OperatingSystem.IsLinux()) + { + socket.SetSocketOption(System.Net.Sockets.SocketOptionLevel.Tcp, + System.Net.Sockets.SocketOptionName.TcpKeepAliveTime, keepAliveSeconds); + } + else if (OperatingSystem.IsMacOS()) + { + // TCP_KEEPALIVE on macOS (: 0x10) is the idle-time knob, + // the analog of Linux TCP_KEEPIDLE. + const int tcpKeepAliveMacOs = 0x10; + socket.SetSocketOption(System.Net.Sockets.SocketOptionLevel.Tcp, + (System.Net.Sockets.SocketOptionName)tcpKeepAliveMacOs, keepAliveSeconds); + } + } + await socket.ConnectAsync( + new System.Net.IPEndPoint(address, endpoint.Port), cancellationToken) + .ConfigureAwait(false); + return new System.Net.Sockets.NetworkStream(socket, ownsSocket: true); + } + catch (System.Exception ex) + { + socket.Dispose(); + lastError = ex; + } + } + + throw lastError ?? new System.Net.Sockets.SocketException( + (int)System.Net.Sockets.SocketError.HostUnreachable); + }; + + // Configure SSL/TLS. Start from the user-supplied options (if any) so client + // certificates, custom CAs, and protocol settings are preserved. When + // SkipCertificateValidation is set we must NOT mutate the caller's options object + // (it is a reference type that may be shared across clients); instead we clone it + // and install the accept-all callback on the copy. + if (settings.Ssl != null || settings.SkipCertificateValidation) { - handler.SslOptions = new System.Net.Security.SslClientAuthenticationOptions + System.Net.Security.SslClientAuthenticationOptions sslOptions; + if (settings.SkipCertificateValidation) { - RemoteCertificateValidationCallback = (_, _, _, _) => true, - }; + sslOptions = CloneSslOptions(settings.Ssl); + sslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true; + } + else + { + sslOptions = settings.Ssl!; + } + handler.SslOptions = sslOptions; } + + // Expose the max response header size. The native handler unit is kilobytes while + // the user provides bytes, so convert (rounding up to avoid silently lowering the cap). + if (settings.MaxResponseHeaderBytes > 0) + { + handler.MaxResponseHeadersLength = + MaxResponseHeaderBytesToKilobytes(settings.MaxResponseHeaderBytes); + } + + if (settings.Proxy != null) + { + handler.Proxy = settings.Proxy; + handler.UseProxy = true; + } + _httpClient = new HttpClient(handler); } @@ -111,7 +218,18 @@ public async Task> SubmitAsync(RequestMessage requestMessage, var headers = new Dictionary(); headers["Accept"] = _responseSerializer.MimeType; - if (_settings.EnableCompression) + // Fill the per-request batch size from the connection-level default when the + // request did not set one. Build a copy for the outgoing request so the caller's + // RequestMessage is never mutated (resubmitting the same message must not pick up + // a previously injected default). A per-request explicit batchSize always wins. + var outgoingMessage = requestMessage; + if (!outgoingMessage.Fields.ContainsKey(Tokens.ArgsBatchSize)) + { + outgoingMessage = outgoingMessage.CloneWithField( + Tokens.ArgsBatchSize, _settings.DefaultBatchSize); + } + + if (_settings.Compression.Type == CompressionType.Deflate) { headers["Accept-Encoding"] = "deflate"; } @@ -129,13 +247,13 @@ public async Task> SubmitAsync(RequestMessage requestMessage, // Promote transactionId to HTTP header before interceptors run. // The field remains in the serialized body as well (dual transmission // per the HTTP transaction protocol specification). - if (requestMessage.Fields.TryGetValue(Tokens.ArgsTransactionId, out var txIdObj) && + if (outgoingMessage.Fields.TryGetValue(Tokens.ArgsTransactionId, out var txIdObj) && txIdObj is string txId && !string.IsNullOrEmpty(txId)) { headers["X-Transaction-Id"] = txId; } - var context = new HttpRequestContext("POST", _uri, headers, requestMessage); + var context = new HttpRequestContext("POST", _uri, headers, outgoingMessage); foreach (var interceptor in _interceptors) { @@ -212,13 +330,26 @@ public async Task> SubmitAsync(RequestMessage requestMessage, { var contentStream = await response.Content.ReadAsStreamAsync() .ConfigureAwait(false); - DeflateStream? deflateStream = null; + + // Apply the per-read idle timeout (if configured) to the raw content stream so it + // covers both the compressed and decompressed read paths. + if (_settings.ReadTimeout > TimeSpan.Zero) + { + contentStream = new ReadTimeoutStream(contentStream, _settings.ReadTimeout); + } + + // The server (gremlin-server HttpContentCompressionHandler) compresses with + // java.util.zip.Deflater's default constructor, which emits a zlib-wrapped + // stream (RFC 1950: 2-byte header + Adler-32 checksum), not raw DEFLATE + // (RFC 1951). ZLibStream understands that wrapper; DeflateStream would throw + // on the zlib header. + Stream? decompressionStream = null; if (response.Content.Headers.ContentEncoding.Contains("deflate")) { - deflateStream = new DeflateStream(contentStream, CompressionMode.Decompress); + decompressionStream = new ZLibStream(contentStream, CompressionMode.Decompress); } streamingContext = new StreamingResponseContext( - response, contentStream, deflateStream); + response, contentStream, decompressionStream); var resultStream = _responseSerializer.DeserializeMessageAsync( streamingContext.Stream, cancellationToken); @@ -283,6 +414,58 @@ await channel.Writer.WriteAsync(item, capturedLinkedCts.Token) } } + /// + /// Converts a maximum response header size expressed in bytes to the kilobyte unit + /// used by , rounding up so + /// the configured byte cap is never silently lowered. For example 1024 bytes maps to + /// 1 KB, 1025 bytes maps to 2 KB, and 8192 bytes maps to 8 KB. Callers only invoke + /// this when is positive. + /// + /// The header cap in bytes (expected to be positive). + /// The equivalent cap in kilobytes, rounded up. + internal static int MaxResponseHeaderBytesToKilobytes(int maxResponseHeaderBytes) + { + return (maxResponseHeaderBytes + 1023) / 1024; + } + + /// + /// Creates a shallow copy of the supplied + /// so the caller's + /// object is never mutated when the skip-cert convenience is applied. Copies the + /// commonly used properties; the accept-all + /// + /// is set on the returned copy by the caller. + /// + /// The caller-owned options to clone, or null. + /// A new options instance carrying the copied settings. + private static System.Net.Security.SslClientAuthenticationOptions CloneSslOptions( + System.Net.Security.SslClientAuthenticationOptions? source) + { + var clone = new System.Net.Security.SslClientAuthenticationOptions(); + if (source == null) + { + return clone; + } + + clone.ClientCertificates = source.ClientCertificates; + clone.EnabledSslProtocols = source.EnabledSslProtocols; + clone.TargetHost = source.TargetHost; + // RemoteCertificateValidationCallback is intentionally NOT copied here: the caller + // overwrites it with the accept-all callback (skip-cert is the only path that clones). + clone.LocalCertificateSelectionCallback = source.LocalCertificateSelectionCallback; + clone.CipherSuitesPolicy = source.CipherSuitesPolicy; + clone.EncryptionPolicy = source.EncryptionPolicy; + clone.ApplicationProtocols = source.ApplicationProtocols; + clone.CertificateRevocationCheckMode = source.CertificateRevocationCheckMode; + clone.AllowRenegotiation = source.AllowRenegotiation; + // ClientCertificateContext carries the mTLS client certificate chain; omitting it + // would break client-certificate auth when combined with skip-cert. + clone.ClientCertificateContext = source.ClientCertificateContext; + // AllowTlsResume defaults to true, so it must be copied to honor a caller's false. + clone.AllowTlsResume = source.AllowTlsResume; + return clone; + } + /// /// Attempts to extract an error message from a JSON response body. /// The server sometimes responds with a JSON object containing a "message" field diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/ConnectionSettings.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/ConnectionSettings.cs index 83faebf7edf..510371c6f1f 100644 --- a/gremlin-dotnet/src/Gremlin.Net/Driver/ConnectionSettings.cs +++ b/gremlin-dotnet/src/Gremlin.Net/Driver/ConnectionSettings.cs @@ -22,6 +22,8 @@ #endregion using System; +using System.Net; +using System.Net.Security; namespace Gremlin.Net.Driver { @@ -31,49 +33,68 @@ namespace Gremlin.Net.Driver public class ConnectionSettings { /// - /// The default TCP connection timeout. + /// The default TCP connect timeout (transport establishment, i.e. TCP connect plus + /// TLS handshake where applicable - not an HTTP request timeout). /// - public static readonly TimeSpan DefaultConnectionTimeout = TimeSpan.FromSeconds(15); + public static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(5); /// /// The default idle connection timeout. /// - public static readonly TimeSpan DefaultIdleConnectionTimeout = TimeSpan.FromSeconds(180); + public static readonly TimeSpan DefaultIdleTimeout = TimeSpan.FromSeconds(180); /// - /// The default maximum connections per server. + /// The default maximum concurrent connections to a single server. /// - public const int DefaultMaxConnectionsPerServer = 128; + public const int DefaultMaxConnections = 128; /// - /// The default TCP keep-alive probe interval. + /// The default maximum concurrent connections to a single server. /// - public static readonly TimeSpan DefaultKeepAliveInterval = TimeSpan.FromSeconds(30); + /// Deprecated compatibility alias; use . + [Obsolete("As of release 4.0.0, replaced by DefaultMaxConnections.")] + public const int DefaultMaxConnectionsPerServer = DefaultMaxConnections; /// - /// Gets or sets the TCP connection timeout. + /// The default TCP keep-alive idle time (how long a connection is idle before the + /// first keep-alive probe is sent). /// - public TimeSpan ConnectionTimeout { get; set; } = DefaultConnectionTimeout; + public static readonly TimeSpan DefaultKeepAliveTime = TimeSpan.FromSeconds(30); + + /// + /// The default batch size used to fill the per-request batch size when it is unset. + /// + public const int DefaultBatchSizeValue = 64; + + /// + /// Gets or sets the TCP connect timeout. This is a transport-establishment timeout + /// (TCP connect plus TLS handshake where applicable), not an HTTP request timeout. + /// + public TimeSpan ConnectTimeout { get; set; } = DefaultConnectTimeout; /// /// Gets or sets how long idle connections stay in the pool before being closed. /// - public TimeSpan IdleConnectionTimeout { get; set; } = DefaultIdleConnectionTimeout; + public TimeSpan IdleTimeout { get; set; } = DefaultIdleTimeout; /// /// Gets or sets the maximum concurrent connections to a single server. /// - public int MaxConnectionsPerServer { get; set; } = DefaultMaxConnectionsPerServer; + public int MaxConnections { get; set; } = DefaultMaxConnections; /// - /// Gets or sets the TCP keep-alive probe interval. + /// Gets or sets the TCP keep-alive idle time: how long a connection may be idle + /// before the first keep-alive probe is sent. Probe interval and count stay at OS + /// defaults. /// - public TimeSpan KeepAliveInterval { get; set; } = DefaultKeepAliveInterval; + public TimeSpan KeepAliveTime { get; set; } = DefaultKeepAliveTime; /// - /// Gets or sets whether to request deflate compression. + /// Gets or sets the response compression algorithm. Defaults to + /// (on); set + /// to disable. /// - public bool EnableCompression { get; set; } = false; + public Compression Compression { get; set; } = Compression.Deflate; /// /// Gets or sets whether to send the User-Agent header. @@ -85,10 +106,107 @@ public class ConnectionSettings /// public bool BulkResults { get; set; } = false; + /// + /// Gets or sets the default batch size that fills the per-request batch size when it + /// is not set on the request (client-side default-filling; no wire change). + /// + public int DefaultBatchSize { get; set; } = DefaultBatchSizeValue; + + /// + /// Gets or sets the SSL/TLS options used for HTTPS connections (client certificates, + /// custom CA, protocols, etc.). When set, these options are used to configure the + /// underlying handler. is applied to an + /// internal copy of these options rather than mutating the object provided here. + /// + public SslClientAuthenticationOptions? Ssl { get; set; } + /// /// Gets or sets whether to skip SSL certificate validation. - /// Only use for testing with self-signed certificates. + /// Only use for testing with self-signed certificates. When is also + /// provided, the accept-all callback is set on an internal copy of those options so the + /// caller's instance is never mutated + /// (which is important when one instance is shared across multiple clients). /// public bool SkipCertificateValidation { get; set; } = false; + + /// + /// Gets or sets the maximum allowed size, in bytes, of the response headers. + /// A value of 0 (the default) leaves the handler default unchanged. The native + /// handler unit is kilobytes; the byte value provided here is converted internally. + /// + public int MaxResponseHeaderBytes { get; set; } = 0; + + /// + /// Gets or sets the idle-read timeout applied to each individual read of the response + /// stream. It resets per chunk, so it is an idle-read timeout rather than a + /// whole-request deadline. + /// (the default) disables it. + /// + public TimeSpan ReadTimeout { get; set; } = System.Threading.Timeout.InfiniteTimeSpan; + + /// + /// Gets or sets the HTTP proxy used for connections. When set, it is applied to the + /// underlying handler explicitly. + /// + public IWebProxy? Proxy { get; set; } + + /// + /// Gets or sets the maximum concurrent connections to a single server. + /// + /// Deprecated compatibility alias; delegates to . + [Obsolete("As of release 4.0.0, replaced by MaxConnections.")] + public int MaxConnectionsPerServer + { + get => MaxConnections; + set => MaxConnections = value; + } + + /// + /// Gets or sets the TCP connect timeout. + /// + /// Deprecated compatibility alias; delegates to . + [Obsolete("As of release 4.0.0, replaced by ConnectTimeout.")] + public TimeSpan ConnectionTimeout + { + get => ConnectTimeout; + set => ConnectTimeout = value; + } + + /// + /// Gets or sets how long idle connections stay in the pool before being closed. + /// + /// Deprecated compatibility alias; delegates to . + [Obsolete("As of release 4.0.0, replaced by IdleTimeout.")] + public TimeSpan IdleConnectionTimeout + { + get => IdleTimeout; + set => IdleTimeout = value; + } + + /// + /// Gets or sets the TCP keep-alive idle time. + /// + /// Deprecated compatibility alias; delegates to . + [Obsolete("As of release 4.0.0, replaced by KeepAliveTime.")] + public TimeSpan KeepAliveInterval + { + get => KeepAliveTime; + set => KeepAliveTime = value; + } + + /// + /// Gets or sets whether response compression is enabled. + /// + /// + /// Deprecated compatibility shim; delegates to + /// (true = , + /// false = ). + /// + [Obsolete("As of release 4.0.0, replaced by Compression.")] + public bool EnableCompression + { + get => Compression.Type == CompressionType.Deflate; + set => Compression = value ? Compression.Deflate : Compression.None; + } } } diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/GremlinServer.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/GremlinServer.cs index 0d8a41dfd18..0e023cfa5c0 100644 --- a/gremlin-dotnet/src/Gremlin.Net/Driver/GremlinServer.cs +++ b/gremlin-dotnet/src/Gremlin.Net/Driver/GremlinServer.cs @@ -43,6 +43,62 @@ public GremlinServer(string hostname = "localhost", int port = 8182, bool enable Uri = CreateUri(hostname, port, enableSsl, path); } + /// + /// Creates a new instance of the class from a single URL. + /// + /// + /// The URL of the Gremlin endpoint, e.g. https://localhost:8182/gremlin. The scheme determines + /// whether SSL is enabled (https enables it, http disables it) and the host, port and path + /// are taken from the URL. When the URL omits the port the default 8182 is used, and when it omits + /// the path the default /gremlin is used. + /// + /// A new configured from the given URL. + /// Thrown when is null. + /// + /// Thrown when is not a valid absolute URL or does not use the + /// http or https scheme. + /// + public static GremlinServer FromUrl(string url) + { + if (url == null) throw new ArgumentNullException(nameof(url)); + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + throw new ArgumentException($"'{url}' is not a valid absolute URL.", nameof(url)); + return new GremlinServer(uri); + } + + /// + /// Initializes a new instance of the class from a . + /// + /// + /// The URI of the Gremlin endpoint. The scheme determines whether SSL is enabled (https enables it, + /// http disables it). When the URI omits the port the default 8182 is used, and when it omits + /// the path the default /gremlin is used. + /// + /// Thrown when is null. + /// + /// Thrown when does not use the http or https scheme. + /// + public GremlinServer(Uri uri) + { + if (uri == null) throw new ArgumentNullException(nameof(uri)); + ValidateScheme(uri.Scheme); + + var enableSsl = string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase); + + // Only override the port when the URL specifies one; otherwise keep the default 8182. + // System.Uri auto-fills the scheme default port (80/443) and flags it via IsDefaultPort, + // so treat that case as "not specified". + var port = uri.IsDefaultPort ? 8182 : uri.Port; + + // Likewise, only override the path when the URL has a non-empty path, otherwise keep the default + // /gremlin. System.Uri turns a path-less URL into AbsolutePath "/", so treat "/" or empty as default. + var path = string.IsNullOrEmpty(uri.AbsolutePath) || uri.AbsolutePath == "/" + ? "/gremlin" + : uri.AbsolutePath; + + Uri = CreateUri(uri.Host, port, enableSsl, path); + } + /// /// Gets the URI of the Gremlin Server. /// @@ -53,5 +109,13 @@ private static Uri CreateUri(string hostname, int port, bool enableSsl, string p var scheme = enableSsl ? "https" : "http"; return new Uri($"{scheme}://{hostname}:{port}{path}"); } + + private static void ValidateScheme(string scheme) + { + if (!string.Equals(scheme, "http", StringComparison.OrdinalIgnoreCase) && + !string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException( + $"Unsupported scheme '{scheme}'. Only 'http' and 'https' are supported."); + } } } diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/Messages/RequestMessage.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/Messages/RequestMessage.cs index 2ebd02cc29d..e843f43d9e4 100644 --- a/gremlin-dotnet/src/Gremlin.Net/Driver/Messages/RequestMessage.cs +++ b/gremlin-dotnet/src/Gremlin.Net/Driver/Messages/RequestMessage.cs @@ -52,6 +52,21 @@ private RequestMessage(string gremlin, Dictionary fields) /// public Dictionary Fields { get; } + /// + /// Returns a copy of this message with set to + /// , without mutating this instance (and therefore without + /// mutating the caller-owned message). Used to fill connection-level defaults onto + /// the outgoing request only. + /// + /// The field key to set on the copy. + /// The field value to set on the copy. + /// A new carrying the added field. + internal RequestMessage CloneWithField(string key, object value) + { + var copiedFields = new Dictionary(Fields) { [key] = value }; + return new RequestMessage(Gremlin, copiedFields); + } + /// /// Initializes a to build a . /// diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/ReadTimeoutStream.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/ReadTimeoutStream.cs new file mode 100644 index 00000000000..aa006ed6a02 --- /dev/null +++ b/gremlin-dotnet/src/Gremlin.Net/Driver/ReadTimeoutStream.cs @@ -0,0 +1,111 @@ +#region License + +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#endregion + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Gremlin.Net.Driver +{ + /// + /// Wraps a stream and applies an idle-read timeout to each individual read by linking a + /// with the caller's token. + /// The timeout resets per chunk, so it is an idle-read timeout rather than a whole-request + /// deadline. A non-positive timeout disables the behavior. + /// + internal sealed class ReadTimeoutStream : Stream + { + private readonly Stream _inner; + private readonly TimeSpan _readTimeout; + + public ReadTimeoutStream(Stream inner, TimeSpan readTimeout) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + _readTimeout = readTimeout; + } + + private bool TimeoutEnabled => _readTimeout > TimeSpan.Zero; + + public override async ValueTask ReadAsync(Memory buffer, + CancellationToken cancellationToken = default) + { + if (!TimeoutEnabled) + { + return await _inner.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + } + + using var timeoutCts = new CancellationTokenSource(); + using var linkedCts = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + timeoutCts.CancelAfter(_readTimeout); + try + { + return await _inner.ReadAsync(buffer, linkedCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && + !cancellationToken.IsCancellationRequested) + { + throw new TimeoutException( + $"Read timed out after {_readTimeout.TotalSeconds:0.###}s waiting for response data."); + } + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, + CancellationToken cancellationToken) + { + return ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + // The driver always reads asynchronously; provide a correct sync fallback. + return _inner.Read(buffer, offset, count); + } + + public override bool CanRead => _inner.CanRead; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() => _inner.Flush(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _inner.Dispose(); + } + base.Dispose(disposing); + } + } +} diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/StreamingResponseContext.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/StreamingResponseContext.cs index 9d40e1f39fe..a61ed2c647e 100644 --- a/gremlin-dotnet/src/Gremlin.Net/Driver/StreamingResponseContext.cs +++ b/gremlin-dotnet/src/Gremlin.Net/Driver/StreamingResponseContext.cs @@ -36,26 +36,26 @@ internal sealed class StreamingResponseContext : IDisposable { private readonly HttpResponseMessage _response; private readonly Stream _contentStream; - private readonly DeflateStream? _deflateStream; + private readonly Stream? _decompressionStream; /// /// Gets the stream to read from — the decompression stream if present, /// otherwise the raw content stream. /// - public Stream Stream => (Stream?)_deflateStream ?? _contentStream; + public Stream Stream => _decompressionStream ?? _contentStream; /// /// Initializes a new instance of the class. /// /// The HTTP response message. /// The raw content stream from the response. - /// An optional deflate decompression stream wrapping the content stream. + /// An optional decompression stream wrapping the content stream. public StreamingResponseContext(HttpResponseMessage response, Stream contentStream, - DeflateStream? deflateStream = null) + Stream? decompressionStream = null) { _response = response; _contentStream = contentStream; - _deflateStream = deflateStream; + _decompressionStream = decompressionStream; } /// @@ -63,7 +63,7 @@ public StreamingResponseContext(HttpResponseMessage response, Stream contentStre /// public void Dispose() { - _deflateStream?.Dispose(); + _decompressionStream?.Dispose(); _contentStream.Dispose(); _response.Dispose(); } diff --git a/gremlin-dotnet/test/Gremlin.Net.Benchmarks/CompressionBenchmarks.cs b/gremlin-dotnet/test/Gremlin.Net.Benchmarks/CompressionBenchmarks.cs index ac51e17f0a4..8e8abf462b0 100644 --- a/gremlin-dotnet/test/Gremlin.Net.Benchmarks/CompressionBenchmarks.cs +++ b/gremlin-dotnet/test/Gremlin.Net.Benchmarks/CompressionBenchmarks.cs @@ -34,14 +34,14 @@ public class CompressionBenchmarks public static async Task GraphBinaryWithoutCompression() { var client = new GremlinClient(new GremlinServer("localhost", 45940), - connectionSettings: new ConnectionSettings { EnableCompression = false }); + connectionSettings: new ConnectionSettings { Compression = Compression.None }); await PerformBenchmarkWithClient(client); } public static async Task GraphBinaryWithCompression() { var client = new GremlinClient(new GremlinServer("localhost", 45940), - connectionSettings: new ConnectionSettings { EnableCompression = true }); + connectionSettings: new ConnectionSettings { Compression = Compression.Deflate }); await PerformBenchmarkWithClient(client); } diff --git a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Docs/Reference/GremlinVariantsTests.cs b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Docs/Reference/GremlinVariantsTests.cs index be2506151e4..b67d7a7ecbd 100644 --- a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Docs/Reference/GremlinVariantsTests.cs +++ b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Docs/Reference/GremlinVariantsTests.cs @@ -143,7 +143,7 @@ public void SubmittingScriptsWithAuthenticationTest() var password = "password"; var gremlinServer = new GremlinServer("localhost", 8182, enableSsl: true); using var gremlinClient = new GremlinClient(gremlinServer, - interceptors: new[] { Auth.BasicAuth(username, password) }); + interceptors: new[] { Auth.Basic(username, password) }); // end::submittingScriptsWithAuthentication[] } diff --git a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Driver/AuthIntegrationTests.cs b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Driver/AuthIntegrationTests.cs index 3b5c9eed072..ca564f987a8 100644 --- a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Driver/AuthIntegrationTests.cs +++ b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Driver/AuthIntegrationTests.cs @@ -53,7 +53,7 @@ public async Task ShouldAuthenticateWithBasicAuth() { // The secure server uses SimpleAuthenticator with credentials: stephen/password using var gremlinClient = CreateSecureClient( - new[] { Auth.BasicAuth("stephen", "password") }); + new[] { Auth.Basic("stephen", "password") }); var response = await gremlinClient.SubmitAsync("g.inject(1).count()"); @@ -65,7 +65,7 @@ public async Task ShouldAuthenticateWithBasicAuthViaDriverRemoteConnection() { // Test through DriverRemoteConnection + traversal using var client = CreateSecureClient( - new[] { Auth.BasicAuth("stephen", "password") }); + new[] { Auth.Basic("stephen", "password") }); using var remote = new DriverRemoteConnection(client, "gmodern"); var g = AnonymousTraversalSource.Traversal().With(remote); @@ -78,7 +78,7 @@ public async Task ShouldAuthenticateWithBasicAuthViaDriverRemoteConnection() public async Task ShouldFailWithWrongCredentials() { using var gremlinClient = CreateSecureClient( - new[] { Auth.BasicAuth("stephen", "wrongpassword") }); + new[] { Auth.Basic("stephen", "wrongpassword") }); // The server returns auth errors as JSON (not GraphBinary), so Connection // extracts the message and throws HttpRequestException. diff --git a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs index 40f5239e446..ea2dc8306c2 100644 --- a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs +++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs @@ -43,7 +43,7 @@ private static HttpRequestContext CreateTestContext() [Fact] public async Task BasicAuthShouldSetCorrectAuthorizationHeader() { - var interceptor = Auth.BasicAuth("user", "pass"); + var interceptor = Auth.Basic("user", "pass"); var context = CreateTestContext(); await interceptor(context); @@ -55,7 +55,7 @@ public async Task BasicAuthShouldSetCorrectAuthorizationHeader() [Fact] public async Task BasicAuthShouldSetHeaderOnEveryInvocation() { - var interceptor = Auth.BasicAuth("user", "pass"); + var interceptor = Auth.Basic("user", "pass"); var context1 = CreateTestContext(); var context2 = CreateTestContext(); @@ -70,7 +70,7 @@ public async Task BasicAuthShouldSetHeaderOnEveryInvocation() [Fact] public async Task BasicAuthShouldHandleColonsInPassword() { - var interceptor = Auth.BasicAuth("user", "pass:with:colons"); + var interceptor = Auth.Basic("user", "pass:with:colons"); var context = CreateTestContext(); await interceptor(context); @@ -83,7 +83,7 @@ public async Task BasicAuthShouldHandleColonsInPassword() [Fact] public async Task BasicAuthShouldHandleUnicodeCharacters() { - var interceptor = Auth.BasicAuth("用户", "密码"); + var interceptor = Auth.Basic("用户", "密码"); var context = CreateTestContext(); await interceptor(context); @@ -96,7 +96,7 @@ public async Task BasicAuthShouldHandleUnicodeCharacters() [Fact] public async Task BasicAuthShouldOverwriteExistingAuthorizationHeader() { - var interceptor = Auth.BasicAuth("user", "pass"); + var interceptor = Auth.Basic("user", "pass"); var context = CreateTestContext(); context.Headers["Authorization"] = "Bearer old-token"; @@ -109,7 +109,7 @@ public async Task BasicAuthShouldOverwriteExistingAuthorizationHeader() [Fact] public async Task BasicAuthShouldHandleEmptyCredentials() { - var interceptor = Auth.BasicAuth("", ""); + var interceptor = Auth.Basic("", ""); var context = CreateTestContext(); await interceptor(context); @@ -140,7 +140,7 @@ private static HttpRequestContext CreateSigv4TestContext(byte[]? body = null) [Fact] public async Task SigV4AuthShouldAddRequiredHeaders() { - var interceptor = Auth.SigV4Auth("gremlin-east-1", "tinkerpop-sigv4", TestBasicCredentials); + var interceptor = Auth.Sigv4("gremlin-east-1", "tinkerpop-sigv4", TestBasicCredentials); var context = CreateSigv4TestContext(); await interceptor(context); @@ -154,7 +154,7 @@ public async Task SigV4AuthShouldAddRequiredHeaders() [Fact] public async Task SigV4AuthShouldHaveCorrectAuthorizationPrefix() { - var interceptor = Auth.SigV4Auth("gremlin-west-2", "tinkerpop-sigv4", TestBasicCredentials); + var interceptor = Auth.Sigv4("gremlin-west-2", "tinkerpop-sigv4", TestBasicCredentials); var context = CreateSigv4TestContext(); await interceptor(context); @@ -166,7 +166,7 @@ public async Task SigV4AuthShouldHaveCorrectAuthorizationPrefix() [Fact] public async Task SigV4AuthShouldAddSessionTokenForTemporaryCredentials() { - var interceptor = Auth.SigV4Auth("gremlin-east-1", "tinkerpop-sigv4", TestSessionCredentials); + var interceptor = Auth.Sigv4("gremlin-east-1", "tinkerpop-sigv4", TestSessionCredentials); var context = CreateSigv4TestContext(); await interceptor(context); @@ -178,7 +178,7 @@ public async Task SigV4AuthShouldAddSessionTokenForTemporaryCredentials() [Fact] public async Task SigV4AuthShouldNotAddSessionTokenForPermanentCredentials() { - var interceptor = Auth.SigV4Auth("gremlin-east-1", "tinkerpop-sigv4", TestBasicCredentials); + var interceptor = Auth.Sigv4("gremlin-east-1", "tinkerpop-sigv4", TestBasicCredentials); var context = CreateSigv4TestContext(); await interceptor(context); @@ -190,7 +190,7 @@ public async Task SigV4AuthShouldNotAddSessionTokenForPermanentCredentials() public async Task SigV4AuthContentHashShouldMatchBodySha256() { var body = new byte[] { 0x84, 0x00, 0xFD, 0x01 }; - var interceptor = Auth.SigV4Auth("gremlin-east-1", "tinkerpop-sigv4", TestBasicCredentials); + var interceptor = Auth.Sigv4("gremlin-east-1", "tinkerpop-sigv4", TestBasicCredentials); var context = CreateSigv4TestContext(body); await interceptor(context); @@ -204,7 +204,7 @@ public async Task SigV4AuthContentHashShouldMatchBodySha256() [Fact] public async Task SigV4AuthShouldHandleEmptyBody() { - var interceptor = Auth.SigV4Auth("gremlin-east-1", "tinkerpop-sigv4", TestBasicCredentials); + var interceptor = Auth.Sigv4("gremlin-east-1", "tinkerpop-sigv4", TestBasicCredentials); var context = CreateSigv4TestContext(Array.Empty()); await interceptor(context); @@ -217,7 +217,7 @@ public async Task SigV4AuthShouldHandleEmptyBody() [Fact] public async Task SigV4AuthShouldSetCorrectHost() { - var interceptor = Auth.SigV4Auth("gremlin-east-1", "tinkerpop-sigv4", TestBasicCredentials); + var interceptor = Auth.Sigv4("gremlin-east-1", "tinkerpop-sigv4", TestBasicCredentials); var context = CreateSigv4TestContext(); await interceptor(context); @@ -228,7 +228,7 @@ public async Task SigV4AuthShouldSetCorrectHost() [Fact] public async Task SigV4AuthShouldThrowWhenBodyIsNotByteArray() { - var interceptor = Auth.SigV4Auth("gremlin-east-1", "tinkerpop-sigv4", TestBasicCredentials); + var interceptor = Auth.Sigv4("gremlin-east-1", "tinkerpop-sigv4", TestBasicCredentials); var context = new HttpRequestContext("POST", new Uri("https://example.com:8182/gremlin"), new Dictionary { @@ -241,5 +241,37 @@ public async Task SigV4AuthShouldThrowWhenBodyIsNotByteArray() Assert.Contains("byte[]", ex.Message); } + + // --- Deprecated alias tests --- + + [Fact] + public async Task BasicAuthAliasShouldBehaveLikeBasic() + { +#pragma warning disable 618 + var interceptor = Auth.BasicAuth("user", "pass"); +#pragma warning restore 618 + var context = CreateTestContext(); + + await interceptor(context); + + var expected = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("user:pass")); + Assert.Equal(expected, context.Headers["Authorization"]); + } + + [Fact] + public async Task SigV4AuthAliasShouldBehaveLikeSigv4() + { +#pragma warning disable 618 + var interceptor = Auth.SigV4Auth("gremlin-east-1", "tinkerpop-sigv4", TestBasicCredentials); +#pragma warning restore 618 + var context = CreateSigv4TestContext(); + + await interceptor(context); + + Assert.True(context.Headers.ContainsKey("Authorization")); + Assert.True(context.Headers.ContainsKey("X-Amz-Date")); + Assert.True(context.Headers.ContainsKey("x-amz-content-sha256")); + Assert.StartsWith("AWS4-HMAC-SHA256 Credential=MOCK_ID", context.Headers["Authorization"]); + } } } diff --git a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionSettingsTests.cs b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionSettingsTests.cs new file mode 100644 index 00000000000..ba87409f01d --- /dev/null +++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionSettingsTests.cs @@ -0,0 +1,165 @@ +#region License + +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#endregion + +using System; +using System.Net.Security; +using System.Threading; +using Gremlin.Net.Driver; +using Xunit; + +namespace Gremlin.Net.UnitTest.Driver +{ + public class ConnectionSettingsTests + { + [Fact] + public void ShouldUseStandardizedDefaults() + { + var settings = new ConnectionSettings(); + + Assert.Equal(TimeSpan.FromSeconds(5), settings.ConnectTimeout); + Assert.Equal(TimeSpan.FromSeconds(180), settings.IdleTimeout); + Assert.Equal(128, settings.MaxConnections); + Assert.Equal(TimeSpan.FromSeconds(30), settings.KeepAliveTime); + Assert.Equal(64, settings.DefaultBatchSize); + Assert.Equal(Compression.Deflate, settings.Compression); + Assert.False(settings.SkipCertificateValidation); + Assert.Null(settings.Ssl); + Assert.Null(settings.Proxy); + Assert.Equal(0, settings.MaxResponseHeaderBytes); + Assert.Equal(Timeout.InfiniteTimeSpan, settings.ReadTimeout); + } + + [Fact] + public void CompressionShouldDefaultToDeflate() + { + Assert.Equal(CompressionType.Deflate, new ConnectionSettings().Compression.Type); + Assert.True(new ConnectionSettings().Compression.Enabled); + } + + [Fact] + public void CompressionShouldAcceptEnumValue() + { + var settings = new ConnectionSettings { Compression = CompressionType.Deflate }; + + Assert.Equal(Compression.Deflate, settings.Compression); + } + + [Fact] + public void CompressionEqualityShouldWork() + { + Assert.Equal(Compression.Deflate, Compression.Deflate); + Assert.NotEqual(Compression.Deflate, Compression.None); + Assert.True(Compression.None == CompressionType.None); + Assert.True(Compression.Deflate != Compression.None); + } + + [Fact] + public void ShouldAllowSettingSslOptions() + { + var ssl = new SslClientAuthenticationOptions { TargetHost = "example.com" }; + var settings = new ConnectionSettings { Ssl = ssl }; + + Assert.Same(ssl, settings.Ssl); + } + + [Fact] + public void ObsoleteAliasesShouldMapToCanonicalProperties() + { +#pragma warning disable 618 + var settings = new ConnectionSettings + { + MaxConnectionsPerServer = 42, + ConnectionTimeout = TimeSpan.FromSeconds(7), + IdleConnectionTimeout = TimeSpan.FromSeconds(99), + KeepAliveInterval = TimeSpan.FromSeconds(11), + }; + + // Reading the obsolete alias returns the canonical value. + Assert.Equal(42, settings.MaxConnectionsPerServer); + Assert.Equal(TimeSpan.FromSeconds(7), settings.ConnectionTimeout); + Assert.Equal(TimeSpan.FromSeconds(99), settings.IdleConnectionTimeout); + Assert.Equal(TimeSpan.FromSeconds(11), settings.KeepAliveInterval); +#pragma warning restore 618 + + // The canonical property reflects the same backing value. + Assert.Equal(42, settings.MaxConnections); + Assert.Equal(TimeSpan.FromSeconds(7), settings.ConnectTimeout); + Assert.Equal(TimeSpan.FromSeconds(99), settings.IdleTimeout); + Assert.Equal(TimeSpan.FromSeconds(11), settings.KeepAliveTime); + } + + [Fact] + public void ObsoleteAliasesShouldReflectCanonicalWrites() + { + var settings = new ConnectionSettings + { + MaxConnections = 256, + ConnectTimeout = TimeSpan.FromSeconds(3), + IdleTimeout = TimeSpan.FromSeconds(60), + KeepAliveTime = TimeSpan.FromSeconds(15), + }; + +#pragma warning disable 618 + Assert.Equal(256, settings.MaxConnectionsPerServer); + Assert.Equal(TimeSpan.FromSeconds(3), settings.ConnectionTimeout); + Assert.Equal(TimeSpan.FromSeconds(60), settings.IdleConnectionTimeout); + Assert.Equal(TimeSpan.FromSeconds(15), settings.KeepAliveInterval); +#pragma warning restore 618 + } + + [Fact] + public void ObsoleteEnableCompressionShouldMapToCompression() + { + var settings = new ConnectionSettings(); + +#pragma warning disable 618 + // Default is Deflate, so the bool shim reads true. + Assert.True(settings.EnableCompression); + + settings.EnableCompression = false; + Assert.Equal(Compression.None, settings.Compression); + Assert.False(settings.EnableCompression); + + settings.EnableCompression = true; + Assert.Equal(Compression.Deflate, settings.Compression); + Assert.True(settings.EnableCompression); +#pragma warning restore 618 + } + + [Fact] + public void ObsoleteEnableCompressionShouldReflectCanonicalCompression() + { + var settings = new ConnectionSettings { Compression = Compression.None }; + +#pragma warning disable 618 + Assert.False(settings.EnableCompression); +#pragma warning restore 618 + + settings.Compression = Compression.Deflate; + +#pragma warning disable 618 + Assert.True(settings.EnableCompression); +#pragma warning restore 618 + } + } +} diff --git a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionTests.cs b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionTests.cs index 56010293637..f488ad632cd 100644 --- a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionTests.cs +++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionTests.cs @@ -141,7 +141,7 @@ public async Task ShouldSetAcceptEncodingWhenCompressionEnabled() { var (httpClient, handler) = CreateMockHttpClient(); var serializer = CreateMockSerializer(); - var settings = new ConnectionSettings { EnableCompression = true }; + var settings = new ConnectionSettings { Compression = Compression.Deflate }; using var connection = new Connection(TestUri, serializer, settings, httpClient); await connection.SubmitAsync(CreateTestRequest()); @@ -156,7 +156,7 @@ public async Task ShouldNotSetAcceptEncodingWhenCompressionDisabled() { var (httpClient, handler) = CreateMockHttpClient(); var serializer = CreateMockSerializer(); - var settings = new ConnectionSettings { EnableCompression = false }; + var settings = new ConnectionSettings { Compression = Compression.None }; using var connection = new Connection(TestUri, serializer, settings, httpClient); await connection.SubmitAsync(CreateTestRequest()); @@ -166,6 +166,21 @@ public async Task ShouldNotSetAcceptEncodingWhenCompressionDisabled() e => e.Value == "deflate"); } + [Fact] + public async Task ShouldSetAcceptEncodingByDefault() + { + var (httpClient, handler) = CreateMockHttpClient(); + var serializer = CreateMockSerializer(); + var settings = new ConnectionSettings(); + using var connection = new Connection(TestUri, serializer, settings, httpClient); + + await connection.SubmitAsync(CreateTestRequest()); + + Assert.NotNull(handler.CapturedRequest); + Assert.Contains(handler.CapturedRequest!.Headers.AcceptEncoding, + e => e.Value == "deflate"); + } + [Fact] public async Task ShouldSetUserAgentWhenEnabled() { @@ -226,21 +241,24 @@ public async Task ShouldNotSetBulkResultsHeaderWhenDisabled() [Fact] public async Task ShouldDecompressDeflateResponse() { - // Compress the minimal response bytes with deflate + // Compress the minimal response bytes the way the server does: java.util.zip.Deflater's + // default constructor emits a zlib-wrapped stream (RFC 1950: 2-byte header + Adler-32), + // which corresponds to .NET's ZLibStream (NOT the raw RFC 1951 DeflateStream). Using + // ZLibStream here exercises the real wire format and would catch a raw/zlib mismatch. var originalBytes = BuildMinimalResponseBytes(); byte[] compressedBytes; using (var compressedStream = new MemoryStream()) { - using (var deflateStream = new DeflateStream(compressedStream, CompressionMode.Compress, true)) + using (var zlibStream = new ZLibStream(compressedStream, CompressionMode.Compress, true)) { - deflateStream.Write(originalBytes, 0, originalBytes.Length); + zlibStream.Write(originalBytes, 0, originalBytes.Length); } compressedBytes = compressedStream.ToArray(); } var (httpClient, handler) = CreateMockHttpClient(compressedBytes, "deflate"); var serializer = CreateMockSerializer(); - var settings = new ConnectionSettings { EnableCompression = true }; + var settings = new ConnectionSettings { Compression = Compression.Deflate }; using var connection = new Connection(TestUri, serializer, settings, httpClient); // Should not throw — decompression should work @@ -262,6 +280,110 @@ public void ShouldDisposeWithoutError() connection.Dispose(); } + [Fact] + public void ShouldNotMutateUserSslOptionsWhenSkippingCertValidation() + { + // The public constructor must NOT mutate the caller's SslClientAuthenticationOptions + // when SkipCertificateValidation is set. The options object is a reference type that + // may be shared across clients, so mutating it in place could silently disable + // validation on another client. Instead, the skip-cert callback must be installed on + // an internal clone, leaving the caller's object untouched. + var userSsl = new System.Net.Security.SslClientAuthenticationOptions + { + TargetHost = "example.com" + }; + var settings = new ConnectionSettings + { + Ssl = userSsl, + SkipCertificateValidation = true + }; + + using var connection = new Connection( + TestUri, CreateMockSerializer(), settings); + + // The caller's own options object must be left exactly as supplied: its + // RemoteCertificateValidationCallback must remain null and its other fields intact. + Assert.Equal("example.com", userSsl.TargetHost); + Assert.Null(userSsl.RemoteCertificateValidationCallback); + // settings.Ssl must still reference the very same object the caller provided. + Assert.Same(userSsl, settings.Ssl); + } + + [Fact] + public void ShouldNotShareSkipCertCallbackAcrossClientsSharingSslOptions() + { + // Reusing one Ssl options object across two clients, only one of which skips cert + // validation, must not leak the accept-all callback onto the shared object (and thus + // onto the other client). + var sharedSsl = new System.Net.Security.SslClientAuthenticationOptions + { + TargetHost = "example.com" + }; + + var skipSettings = new ConnectionSettings + { + Ssl = sharedSsl, + SkipCertificateValidation = true + }; + var strictSettings = new ConnectionSettings + { + Ssl = sharedSsl, + SkipCertificateValidation = false + }; + + using var skipConnection = new Connection(TestUri, CreateMockSerializer(), skipSettings); + using var strictConnection = new Connection(TestUri, CreateMockSerializer(), strictSettings); + + // The shared object must never have had the accept-all callback written onto it. + Assert.Null(sharedSsl.RemoteCertificateValidationCallback); + } + + [Fact] + public void ShouldConstructWithProxyAndMaxHeaderBytes() + { + var settings = new ConnectionSettings + { + Proxy = new System.Net.WebProxy("http://localhost:3128"), + MaxResponseHeaderBytes = 16384 + }; + + // Should construct the handler without throwing. + using var connection = new Connection( + TestUri, CreateMockSerializer(), settings); + } + + [Theory] + [InlineData(1, 1)] // a single byte still needs one whole kilobyte + [InlineData(1023, 1)] // just under 1 KB rounds up to 1 + [InlineData(1024, 1)] // exactly 1 KB stays 1 (no spurious round-up) + [InlineData(1025, 2)] // one byte over a KB boundary rounds up to 2 + [InlineData(8191, 8)] // just under 8 KB rounds up to 8 + [InlineData(8192, 8)] // exactly 8 KB (the default) stays 8 + [InlineData(8193, 9)] // one byte over rounds up to 9 + [InlineData(16384, 16)] // exactly 16 KB stays 16 + public void ShouldRoundMaxResponseHeaderBytesUpToKilobytes(int bytes, int expectedKilobytes) + { + // SocketsHttpHandler.MaxResponseHeadersLength is expressed in kilobytes while the + // public option is in bytes. The conversion must round UP so the configured byte cap + // is never silently lowered (ceil(bytes / 1024)). This asserts the rounding math the + // public Connection constructor applies to the handler. + Assert.Equal(expectedKilobytes, Connection.MaxResponseHeaderBytesToKilobytes(bytes)); + } + + [Fact] + public void ShouldLeaveMaxResponseHeadersAtDefaultWhenBytesUnset() + { + // When MaxResponseHeaderBytes is 0 (the default / unset), the constructor must NOT + // touch the handler's MaxResponseHeadersLength, leaving the .NET default in place. + // The conversion is only applied for a positive byte cap, so constructing with the + // default must succeed without invoking the rounding path. + var settings = new ConnectionSettings(); + Assert.Equal(0, settings.MaxResponseHeaderBytes); + + // Should construct without throwing and without configuring the header cap. + using var connection = new Connection(TestUri, CreateMockSerializer(), settings); + } + private static RequestMessage CreateTestRequest() { return RequestMessage.Build("g.V()").AddG("g").Create(); @@ -940,6 +1062,162 @@ public async Task ShouldStreamResponseWithoutFullBuffering() Assert.True(handler.WasCalled, "SendAsync should have been called"); } + [Fact] + public async Task ShouldFillBatchSizeFromDefaultWhenUnset() + { + var (httpClient, handler) = CreateMockHttpClient(); + var serializer = CreateMockSerializer(); + var settings = new ConnectionSettings { DefaultBatchSize = 42 }; + using var connection = new Connection(TestUri, serializer, settings, httpClient); + + var request = CreateTestRequest(); + await connection.SubmitAsync(request); + + // The caller-owned request must NOT be mutated by default-filling. + Assert.False(request.Fields.ContainsKey(Tokens.ArgsBatchSize)); + // The outgoing wire payload must carry the connection-level default. + Assert.Equal(42, await ReadBatchSizeFromBodyAsync(handler)); + } + + [Fact] + public async Task ShouldNotOverrideExplicitBatchSize() + { + var (httpClient, handler) = CreateMockHttpClient(); + var serializer = CreateMockSerializer(); + var settings = new ConnectionSettings { DefaultBatchSize = 42 }; + using var connection = new Connection(TestUri, serializer, settings, httpClient); + + var request = RequestMessage.Build("g.V()").AddG("g").AddBatchSize(100).Create(); + await connection.SubmitAsync(request); + + // A per-request explicit batchSize always wins, on the caller object and the wire. + Assert.Equal(100, request.Fields[Tokens.ArgsBatchSize]); + Assert.Equal(100, await ReadBatchSizeFromBodyAsync(handler)); + } + + [Fact] + public async Task ShouldUseDefaultBatchSizeOf64ByDefault() + { + var (httpClient, handler) = CreateMockHttpClient(); + var serializer = CreateMockSerializer(); + var settings = new ConnectionSettings(); + using var connection = new Connection(TestUri, serializer, settings, httpClient); + + var request = CreateTestRequest(); + await connection.SubmitAsync(request); + + Assert.False(request.Fields.ContainsKey(Tokens.ArgsBatchSize)); + Assert.Equal(64, await ReadBatchSizeFromBodyAsync(handler)); + } + + [Fact] + public async Task ShouldNotPersistDefaultBatchSizeAcrossResubmissions() + { + var (httpClient, handler) = CreateMockHttpClient(); + var serializer = CreateMockSerializer(); + var settings = new ConnectionSettings { DefaultBatchSize = 42 }; + using var connection = new Connection(TestUri, serializer, settings, httpClient); + + // Resubmitting the same message must not carry over a previously injected default. + var request = CreateTestRequest(); + await connection.SubmitAsync(request); + await connection.SubmitAsync(request); + + Assert.False(request.Fields.ContainsKey(Tokens.ArgsBatchSize)); + Assert.Equal(42, await ReadBatchSizeFromBodyAsync(handler)); + } + + /// + /// Reads the serialized JSON request body captured by the mock handler and returns + /// the batchSize field value, or null when it is absent. + /// + private static async Task ReadBatchSizeFromBodyAsync(MockHandler handler) + { + Assert.NotNull(handler.CapturedRequest); + var bodyBytes = await handler.CapturedRequest!.Content!.ReadAsByteArrayAsync(); + using var doc = System.Text.Json.JsonDocument.Parse(bodyBytes); + if (doc.RootElement.TryGetProperty(Tokens.ArgsBatchSize, out var batchSizeProp)) + { + return batchSizeProp.GetInt32(); + } + return null; + } + + [Fact] + public async Task ShouldTimeOutSlowReadWhenReadTimeoutSet() + { + // A response stream that blocks indefinitely on read should trigger the + // per-read idle timeout once it is consumed during deserialization. + var blockingStream = new BlockingStream(); + var handler = new StreamMockHandler(blockingStream); + var httpClient = new HttpClient(handler); + // The serializer actually reads from the stream so the read timeout can fire. + var serializer = CreateReadingSerializer(); + var settings = new ConnectionSettings + { + ReadTimeout = TimeSpan.FromMilliseconds(100) + }; + using var connection = new Connection(TestUri, serializer, settings, httpClient); + + var result = await connection.SubmitAsync(CreateTestRequest()); + + // The background streaming task surfaces the timeout as a faulted enumeration. + await Assert.ThrowsAnyAsync(async () => + { + await foreach (var _ in result) { } + }); + } + + private static IMessageSerializer CreateReadingSerializer( + string mimeType = SerializationTokens.GraphBinary4MimeType) + { + var serializer = Substitute.For(); + serializer.MimeType.Returns(mimeType); + serializer.DeserializeMessageAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => ReadAllAsync((Stream)callInfo[0], (CancellationToken)callInfo[1])); + return serializer; + } + + private static async IAsyncEnumerable ReadAllAsync(Stream stream, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + var buffer = new byte[16]; + while (await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false) > 0) + { + yield return new object(); + } + } + + /// + /// A stream whose ReadAsync never completes until cancelled, used to exercise the + /// per-read timeout. + /// + private sealed class BlockingStream : Stream + { + public override async ValueTask ReadAsync(Memory buffer, + CancellationToken cancellationToken = default) + { + await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); + return 0; + } + + public override int Read(byte[] buffer, int offset, int count) + { + Thread.Sleep(Timeout.Infinite); + return 0; + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => 0; set { } } + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } + /// /// A test HttpMessageHandler that captures the request and returns a canned response. /// The response uses ByteArrayContent but does NOT dispose the content stream diff --git a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/GremlinClientTests.cs b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/GremlinClientTests.cs index 58442eb7d12..83518bc3b49 100644 --- a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/GremlinClientTests.cs +++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/GremlinClientTests.cs @@ -51,7 +51,7 @@ public void ShouldCreateClientWithCustomSettings() { var settings = new ConnectionSettings { - EnableCompression = true, + Compression = Compression.Deflate, BulkResults = true }; using var client = new GremlinClient(new GremlinServer(), connectionSettings: settings); diff --git a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/GremlinServerTests.cs b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/GremlinServerTests.cs index add13d169f6..35e6113b80d 100644 --- a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/GremlinServerTests.cs +++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/GremlinServerTests.cs @@ -21,6 +21,7 @@ #endregion +using System; using Gremlin.Net.Driver; using Xunit; @@ -80,5 +81,140 @@ public void ShouldUseDefaultGremlinPath() Assert.Equal("/gremlin", gremlinServer.Uri.AbsolutePath); } + + [Fact] + public void ShouldBuildFromHttpUrl() + { + var gremlinServer = GremlinServer.FromUrl("http://example.com:8182/gremlin"); + + var uri = gremlinServer.Uri; + + Assert.Equal("http", uri.Scheme); + Assert.Equal("example.com", uri.Host); + Assert.Equal(8182, uri.Port); + Assert.Equal("/gremlin", uri.AbsolutePath); + } + + [Fact] + public void ShouldBuildFromHttpsUrl() + { + var gremlinServer = GremlinServer.FromUrl("https://example.com:8182/gremlin"); + + var uri = gremlinServer.Uri; + + Assert.Equal("https", uri.Scheme); + Assert.Equal("example.com", uri.Host); + Assert.Equal(8182, uri.Port); + Assert.Equal("/gremlin", uri.AbsolutePath); + } + + [Fact] + public void ShouldBuildFromUrlWithCustomPath() + { + var gremlinServer = GremlinServer.FromUrl("https://example.com:8182/custom/path"); + + var uri = gremlinServer.Uri; + + Assert.Equal("/custom/path", uri.AbsolutePath); + Assert.Equal("https://example.com:8182/custom/path", uri.AbsoluteUri); + } + + [Theory] + [InlineData("http://example.com:8182/gremlin", "example.com", 8182)] + [InlineData("https://1.2.3.4:5678/gremlin", "1.2.3.4", 5678)] + public void ShouldExtractHostAndPortFromUrl(string url, string expectedHost, int expectedPort) + { + var gremlinServer = GremlinServer.FromUrl(url); + + Assert.Equal(expectedHost, gremlinServer.Uri.Host); + Assert.Equal(expectedPort, gremlinServer.Uri.Port); + } + + [Fact] + public void ShouldParseSecureUrlWithExplicitPortAndPath() + { + var uri = GremlinServer.FromUrl("https://h:1234/p").Uri; + + Assert.Equal("https", uri.Scheme); + Assert.Equal("h", uri.Host); + Assert.Equal(1234, uri.Port); + Assert.Equal("/p", uri.AbsolutePath); + } + + [Fact] + public void ShouldParseInsecureUrlWithExplicitPortAndPath() + { + var uri = GremlinServer.FromUrl("http://h:1234/p").Uri; + + Assert.Equal("http", uri.Scheme); + Assert.Equal("h", uri.Host); + Assert.Equal(1234, uri.Port); + Assert.Equal("/p", uri.AbsolutePath); + } + + [Fact] + public void ShouldDefaultPortWhenUrlOmitsPort() + { + var uri = GremlinServer.FromUrl("https://host/gremlin").Uri; + + Assert.Equal("https", uri.Scheme); + Assert.Equal("host", uri.Host); + Assert.Equal(8182, uri.Port); + Assert.Equal("/gremlin", uri.AbsolutePath); + } + + [Fact] + public void ShouldDefaultPathWhenUrlOmitsPath() + { + var uri = GremlinServer.FromUrl("https://host:8182").Uri; + + Assert.Equal("https", uri.Scheme); + Assert.Equal("host", uri.Host); + Assert.Equal(8182, uri.Port); + Assert.Equal("/gremlin", uri.AbsolutePath); + } + + [Fact] + public void ShouldDefaultPortAndPathWhenUrlOmitsBoth() + { + var uri = GremlinServer.FromUrl("https://host").Uri; + + Assert.Equal("https", uri.Scheme); + Assert.Equal("host", uri.Host); + Assert.Equal(8182, uri.Port); + Assert.Equal("/gremlin", uri.AbsolutePath); + } + + [Fact] + public void ShouldRejectUnsupportedSchemeForUrl() + { + Assert.Throws(() => GremlinServer.FromUrl("ws://example.com:8182/gremlin")); + } + + [Fact] + public void ShouldRejectNonAbsoluteUrl() + { + Assert.Throws(() => GremlinServer.FromUrl("example.com:8182/gremlin")); + } + + [Fact] + public void ShouldRejectNullUrl() + { + Assert.Throws(() => GremlinServer.FromUrl(null!)); + } + + [Fact] + public void ShouldBuildFromUri() + { + var gremlinServer = new GremlinServer(new Uri("https://example.com:8182/gremlin")); + + Assert.Equal("https://example.com:8182/gremlin", gremlinServer.Uri.AbsoluteUri); + } + + [Fact] + public void ShouldRejectUriWithUnsupportedScheme() + { + Assert.Throws(() => new GremlinServer(new Uri("ftp://example.com:8182/gremlin"))); + } } } From a36b5ffec288cbf361936d48b35758f6f8a4c7ef Mon Sep 17 00:00:00 2001 From: Guian Gumpac Date: Tue, 23 Jun 2026 21:14:33 -0700 Subject: [PATCH 2/4] GLV parity based on comments in other PRs Timeout milliseconds DefaultBatchSize to BatchSize Removed deprecated options Assisted-by: Kiro: Claude Opus 4.8 --- CHANGELOG.asciidoc | 11 +- docs/src/reference/gremlin-variants.asciidoc | 10 +- docs/src/upgrade/release-4.x.x.asciidoc | 15 +-- gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs | 25 ---- .../src/Gremlin.Net/Driver/Connection.cs | 2 +- .../Gremlin.Net/Driver/ConnectionSettings.cs | 108 +++++++----------- .../Gremlin.Net.UnitTest/Driver/AuthTests.cs | 29 ----- .../Driver/ConnectionSettingsTests.cs | 78 +++---------- .../Driver/ConnectionTests.cs | 6 +- 9 files changed, 84 insertions(+), 200 deletions(-) diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc index 5db52cdc165..294d9d8596e 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -26,14 +26,15 @@ image::https://raw.githubusercontent.com/apache/tinkerpop/master/docs/static/ima === TinkerPop 4.0.0 (Release Date: NOT OFFICIALLY RELEASED YET) * Standardized `gremlin-dotnet` connection options per the TinkerPop 4.x GLV proposal: -** Renamed the `Auth.BasicAuth`/`Auth.SigV4Auth` factory methods to `Auth.Basic`/`Auth.Sigv4`; the old methods are retained as `[Obsolete]` deprecated aliases that delegate to the canonical members, so existing code keeps compiling. -** Renamed `MaxConnectionsPerServer` to `MaxConnections`, `IdleConnectionTimeout` to `IdleTimeout`, and `KeepAliveInterval` to `KeepAliveTime` (now wired to a real TCP keep-alive socket option rather than the inert HTTP/2 ping timeout, enabling `SO_KEEPALIVE` and setting the per-socket idle time on Windows, Linux, and macOS, with a no-op fallback to the OS default idle time on other platforms); the old property names are retained as `[Obsolete]` deprecated aliases. -** Renamed `ConnectionTimeout` to `ConnectTimeout` (default lowered from 15s to 5s; old name retained as a deprecated alias). *(breaking)* -** Renamed `EnableCompression` to `Compression`, now a `{None, Deflate}` enum defaulting to `Deflate` (compression on by default; set `None` to disable). The deprecated `EnableCompression` `bool` is retained as a compatibility shim mapping `true`/`false` to `Compression.Deflate`/`Compression.None`. *(breaking)* +** Renamed the `Auth.BasicAuth`/`Auth.SigV4Auth` factory methods to `Auth.Basic`/`Auth.Sigv4` (breaking; the old methods have been removed). *(breaking)* +** Renamed `MaxConnectionsPerServer` to `MaxConnections`, `IdleConnectionTimeout` to `IdleTimeout`, and `KeepAliveInterval` to `KeepAliveTime` (now wired to a real TCP keep-alive socket option rather than the inert HTTP/2 ping timeout, enabling `SO_KEEPALIVE` and setting the per-socket idle time on Windows, Linux, and macOS, with a no-op fallback to the OS default idle time on other platforms); the old property names have been removed. *(breaking)* +** Renamed `ConnectionTimeout` to `ConnectTimeout` (default lowered from 15s to 5s; old name removed). *(breaking)* +** Renamed `EnableCompression` to `Compression`, now a `{None, Deflate}` enum defaulting to `Deflate` (compression on by default; set `None` to disable). The old `EnableCompression` `bool` has been removed. *(breaking)* ** Added `Ssl` (an `SslClientAuthenticationOptions`; `SkipCertificateValidation` is applied to an internal copy rather than mutating the caller's options). -** Added `DefaultBatchSize` (default 64), a connection-level default that fills the per-request `batchSize` when unset. +** Added `BatchSize` (default 64), a connection-level default that fills the per-request `batchSize` when unset. ** Added `MaxResponseHeaderBytes`, exposing the handler's maximum response header size. ** Added `ReadTimeout`, a per-read idle timeout applied to each read of the response stream. +** Each timeout option is also settable in milliseconds via an `int` companion property (`ConnectTimeoutMillis`, `IdleTimeoutMillis`, `ReadTimeoutMillis`, `KeepAliveTimeMillis`); the unsuffixed `TimeSpan` property remains the idiomatic form and both reflect the same value. ** Added `Proxy`, routing connections through an `IWebProxy`. * Fixed `gremlin-dotnet` deflate response decompression, which threw on the server's zlib-framed output because it used `DeflateStream` (raw DEFLATE, RFC 1951) instead of `ZLibStream` (zlib, RFC 1950); the bug was previously masked because compression was off by default. * Fixed `gremlin-dotnet` SSL options cloning (used on the skip-certificate-validation path) to copy `ClientCertificateContext` and `AllowTlsResume`, which were previously dropped, breaking mTLS client certificates and silently re-enabling TLS resumption. diff --git a/docs/src/reference/gremlin-variants.asciidoc b/docs/src/reference/gremlin-variants.asciidoc index df51df3570e..3419e99883d 100644 --- a/docs/src/reference/gremlin-variants.asciidoc +++ b/docs/src/reference/gremlin-variants.asciidoc @@ -2658,15 +2658,15 @@ constructor: [width="100%",cols="3,10,^2",options="header"] |========================================================= |Key |Description |Default -|ConnectTimeout |The TCP connect timeout (transport establishment, i.e. TCP connect plus TLS handshake where applicable, not an HTTP request timeout). |5 s -|IdleTimeout |How long idle connections stay in the pool before being closed. |180 s +|ConnectTimeoutMillis |The TCP connect timeout in milliseconds (transport establishment, i.e. TCP connect plus TLS handshake where applicable, not an HTTP request timeout). Also settable as `ConnectTimeout` (a `TimeSpan`). |5000 +|IdleTimeoutMillis |How long in milliseconds idle connections stay in the pool before being closed. Also settable as `IdleTimeout` (a `TimeSpan`). |180000 |MaxConnections |The maximum concurrent connections to a single server. |128 -|KeepAliveTime |Idle time before TCP keep-alive probes begin on an otherwise idle connection. Enables `SO_KEEPALIVE` on the socket and sets the per-socket idle time on Windows, Linux, and macOS; on other platforms keep-alive stays enabled at the OS default idle time. |30 s +|KeepAliveTimeMillis |Idle time in milliseconds before TCP keep-alive probes begin on an otherwise idle connection. Enables `SO_KEEPALIVE` on the socket and sets the per-socket idle time on Windows, Linux, and macOS; on other platforms keep-alive stays enabled at the OS default idle time. Also settable as `KeepAliveTime` (a `TimeSpan`). |30000 |Compression |The response compression algorithm (`Compression.None` or `Compression.Deflate`). |`Compression.Deflate` -|DefaultBatchSize |The default batch size used to fill the per-request batch size when it is unset. |64 +|BatchSize |The connection-level default batch size used to fill the per-request batch size when it is unset. |64 |Ssl |The `SslClientAuthenticationOptions` used for HTTPS connections (client certificates, custom CA, protocols, etc.). `SkipCertificateValidation` is applied to an internal copy of these options rather than mutating the object you provide. |`null` |MaxResponseHeaderBytes |The maximum allowed size, in bytes, of the response headers. `0` leaves the handler default unchanged (converted internally to the handler's native kilobyte unit). |0 -|ReadTimeout |The idle-read timeout applied to each individual read of the response stream. It resets per chunk. `Timeout.InfiniteTimeSpan` disables it. |`Timeout.InfiniteTimeSpan` +|ReadTimeoutMillis |The idle-read timeout in milliseconds applied to each individual read of the response stream. It resets per chunk. `0` (the default) disables it. Also settable as `ReadTimeout` (a `TimeSpan`; `Timeout.InfiniteTimeSpan` disables). |0 |Proxy |The `IWebProxy` used for connections. |`null` |EnableUserAgentOnConnect |Enables sending a user agent to the server on requests. More details can be found in provider docs diff --git a/docs/src/upgrade/release-4.x.x.asciidoc b/docs/src/upgrade/release-4.x.x.asciidoc index e991ca614e8..f10e7e4aa00 100644 --- a/docs/src/upgrade/release-4.x.x.asciidoc +++ b/docs/src/upgrade/release-4.x.x.asciidoc @@ -35,13 +35,12 @@ complete list of all the modifications that are part of this release. ==== Standardizing .NET Connection Options TinkerPop 4.x standardizes connection option names and defaults across the GLVs. In `gremlin-dotnet`, several -`ConnectionSettings` properties and the `Auth` factory methods have been renamed for consistency, with the old names -retained as `[Obsolete]` deprecated aliases so that existing code keeps compiling, and a number of new options have -been added. The notes below describe the .NET changes. See <> for the +`ConnectionSettings` properties and the `Auth` factory methods have been renamed for consistency. Because this is a +major version, the old names have been removed rather than retained as aliases, and a number of new options have been +added. The notes below describe the .NET changes. See <> for the equivalent changes in the other drivers. -Renames (deprecated aliases). The following members have been renamed. The old names still work but are deprecated -and should be migrated to the new names: +Renames (breaking). The following members have been renamed and the old names removed. Migrate to the new names: - `ConnectionTimeout` is now `ConnectTimeout`. - `MaxConnectionsPerServer` is now `MaxConnections`. @@ -64,12 +63,14 @@ New options: - `Ssl` (an `SslClientAuthenticationOptions` for client certificates and custom CAs; `SkipCertificateValidation` is applied to an internal copy rather than mutating the supplied options). -- `DefaultBatchSize` (default 64): a connection-level default that fills the per-request batch size when unset. +- `BatchSize` (default 64): a connection-level default that fills the per-request batch size when unset. - `MaxResponseHeaderBytes`: the maximum allowed size, in bytes, of the response headers. - `ReadTimeout`: a per-read idle timeout applied to each individual read of the response stream. +- Each timeout is also settable in milliseconds via an `int` companion property (`ConnectTimeoutMillis`, `IdleTimeoutMillis`, + `ReadTimeoutMillis`, `KeepAliveTimeMillis`); the unsuffixed `TimeSpan` property is the idiomatic form. - `Proxy`: routes connections through an `IWebProxy`. -See: link:https://lists.apache.org/thread/yqtr2wnb1kq2pqqq4002cz511q5o0bkg[dev list discussion on standardizing GLV connection options]. +See: link:https://lists.apache.org/thread/yqtr2wnb1kq2pqqq4002cz511q5o0bkg[[DISCUSS] Standardizing GLV connection options in TinkerPop 4]. ==== Standardizing Go Connection Options diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs index 0e842465faf..d713dc94734 100644 --- a/gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs +++ b/gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs @@ -105,31 +105,6 @@ public static Func Sigv4( }; } - /// - /// Returns a request interceptor that adds an HTTP Basic Authentication header. - /// - /// The username. - /// The password. - /// A request interceptor delegate. - /// Deprecated compatibility alias; delegates to . - [Obsolete("As of release 4.0.0, replaced by Basic.")] - public static Func BasicAuth(string username, string password) => - Basic(username, password); - - /// - /// Returns a request interceptor that signs requests using AWS Signature Version 4. - /// - /// The AWS region (e.g. "us-east-1"). - /// The AWS service name (e.g. "neptune-db"). - /// - /// Optional AWS credentials. When null, the default credential chain is used. - /// - /// A request interceptor delegate. - /// Deprecated compatibility alias; delegates to . - [Obsolete("As of release 4.0.0, replaced by Sigv4.")] - public static Func SigV4Auth( - string region, string service, AWSCredentials? credentials = null) => - Sigv4(region, service, credentials); private static void SignRequest(HttpRequestContext context, ImmutableCredentials credentials, AWS4Signer signer, SigningClientConfig clientConfig) diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs index 9b272d8fe23..7e16bffea9a 100644 --- a/gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs +++ b/gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs @@ -226,7 +226,7 @@ public async Task> SubmitAsync(RequestMessage requestMessage, if (!outgoingMessage.Fields.ContainsKey(Tokens.ArgsBatchSize)) { outgoingMessage = outgoingMessage.CloneWithField( - Tokens.ArgsBatchSize, _settings.DefaultBatchSize); + Tokens.ArgsBatchSize, _settings.BatchSize); } if (_settings.Compression.Type == CompressionType.Deflate) diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/ConnectionSettings.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/ConnectionSettings.cs index 510371c6f1f..5381acc0557 100644 --- a/gremlin-dotnet/src/Gremlin.Net/Driver/ConnectionSettings.cs +++ b/gremlin-dotnet/src/Gremlin.Net/Driver/ConnectionSettings.cs @@ -48,13 +48,6 @@ public class ConnectionSettings /// public const int DefaultMaxConnections = 128; - /// - /// The default maximum concurrent connections to a single server. - /// - /// Deprecated compatibility alias; use . - [Obsolete("As of release 4.0.0, replaced by DefaultMaxConnections.")] - public const int DefaultMaxConnectionsPerServer = DefaultMaxConnections; - /// /// The default TCP keep-alive idle time (how long a connection is idle before the /// first keep-alive probe is sent). @@ -72,11 +65,31 @@ public class ConnectionSettings /// public TimeSpan ConnectTimeout { get; set; } = DefaultConnectTimeout; + /// + /// Gets or sets in whole milliseconds. This is the millisecond + /// view of the same setting; is the idiomatic form. + /// + public int ConnectTimeoutMillis + { + get => (int) ConnectTimeout.TotalMilliseconds; + set => ConnectTimeout = TimeSpan.FromMilliseconds(value); + } + /// /// Gets or sets how long idle connections stay in the pool before being closed. /// public TimeSpan IdleTimeout { get; set; } = DefaultIdleTimeout; + /// + /// Gets or sets in whole milliseconds. This is the millisecond + /// view of the same setting; is the idiomatic form. + /// + public int IdleTimeoutMillis + { + get => (int) IdleTimeout.TotalMilliseconds; + set => IdleTimeout = TimeSpan.FromMilliseconds(value); + } + /// /// Gets or sets the maximum concurrent connections to a single server. /// @@ -89,6 +102,16 @@ public class ConnectionSettings /// public TimeSpan KeepAliveTime { get; set; } = DefaultKeepAliveTime; + /// + /// Gets or sets in whole milliseconds. This is the millisecond + /// view of the same setting; is the idiomatic form. + /// + public int KeepAliveTimeMillis + { + get => (int) KeepAliveTime.TotalMilliseconds; + set => KeepAliveTime = TimeSpan.FromMilliseconds(value); + } + /// /// Gets or sets the response compression algorithm. Defaults to /// (on); set @@ -107,10 +130,10 @@ public class ConnectionSettings public bool BulkResults { get; set; } = false; /// - /// Gets or sets the default batch size that fills the per-request batch size when it - /// is not set on the request (client-side default-filling; no wire change). + /// Gets or sets the connection-level default batch size that fills the per-request batch size + /// when it is not set on the request (client-side default-filling; no wire change). /// - public int DefaultBatchSize { get; set; } = DefaultBatchSizeValue; + public int BatchSize { get; set; } = DefaultBatchSizeValue; /// /// Gets or sets the SSL/TLS options used for HTTPS connections (client certificates, @@ -145,68 +168,21 @@ public class ConnectionSettings public TimeSpan ReadTimeout { get; set; } = System.Threading.Timeout.InfiniteTimeSpan; /// - /// Gets or sets the HTTP proxy used for connections. When set, it is applied to the - /// underlying handler explicitly. - /// - public IWebProxy? Proxy { get; set; } - - /// - /// Gets or sets the maximum concurrent connections to a single server. - /// - /// Deprecated compatibility alias; delegates to . - [Obsolete("As of release 4.0.0, replaced by MaxConnections.")] - public int MaxConnectionsPerServer - { - get => MaxConnections; - set => MaxConnections = value; - } - - /// - /// Gets or sets the TCP connect timeout. + /// Gets or sets in whole milliseconds, where 0 disables it + /// (mapping to ). This is the millisecond + /// view of the same setting; is the idiomatic form. /// - /// Deprecated compatibility alias; delegates to . - [Obsolete("As of release 4.0.0, replaced by ConnectTimeout.")] - public TimeSpan ConnectionTimeout + public int ReadTimeoutMillis { - get => ConnectTimeout; - set => ConnectTimeout = value; + get => ReadTimeout <= TimeSpan.Zero ? 0 : (int) ReadTimeout.TotalMilliseconds; + set => ReadTimeout = value <= 0 ? System.Threading.Timeout.InfiniteTimeSpan : TimeSpan.FromMilliseconds(value); } /// - /// Gets or sets how long idle connections stay in the pool before being closed. - /// - /// Deprecated compatibility alias; delegates to . - [Obsolete("As of release 4.0.0, replaced by IdleTimeout.")] - public TimeSpan IdleConnectionTimeout - { - get => IdleTimeout; - set => IdleTimeout = value; - } - - /// - /// Gets or sets the TCP keep-alive idle time. + /// Gets or sets the HTTP proxy used for connections. When set, it is applied to the + /// underlying handler explicitly. /// - /// Deprecated compatibility alias; delegates to . - [Obsolete("As of release 4.0.0, replaced by KeepAliveTime.")] - public TimeSpan KeepAliveInterval - { - get => KeepAliveTime; - set => KeepAliveTime = value; - } + public IWebProxy? Proxy { get; set; } - /// - /// Gets or sets whether response compression is enabled. - /// - /// - /// Deprecated compatibility shim; delegates to - /// (true = , - /// false = ). - /// - [Obsolete("As of release 4.0.0, replaced by Compression.")] - public bool EnableCompression - { - get => Compression.Type == CompressionType.Deflate; - set => Compression = value ? Compression.Deflate : Compression.None; - } } } diff --git a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs index ea2dc8306c2..a31b23f0253 100644 --- a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs +++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs @@ -244,34 +244,5 @@ public async Task SigV4AuthShouldThrowWhenBodyIsNotByteArray() // --- Deprecated alias tests --- - [Fact] - public async Task BasicAuthAliasShouldBehaveLikeBasic() - { -#pragma warning disable 618 - var interceptor = Auth.BasicAuth("user", "pass"); -#pragma warning restore 618 - var context = CreateTestContext(); - - await interceptor(context); - - var expected = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("user:pass")); - Assert.Equal(expected, context.Headers["Authorization"]); - } - - [Fact] - public async Task SigV4AuthAliasShouldBehaveLikeSigv4() - { -#pragma warning disable 618 - var interceptor = Auth.SigV4Auth("gremlin-east-1", "tinkerpop-sigv4", TestBasicCredentials); -#pragma warning restore 618 - var context = CreateSigv4TestContext(); - - await interceptor(context); - - Assert.True(context.Headers.ContainsKey("Authorization")); - Assert.True(context.Headers.ContainsKey("X-Amz-Date")); - Assert.True(context.Headers.ContainsKey("x-amz-content-sha256")); - Assert.StartsWith("AWS4-HMAC-SHA256 Credential=MOCK_ID", context.Headers["Authorization"]); - } } } diff --git a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionSettingsTests.cs b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionSettingsTests.cs index ba87409f01d..19d37e93cca 100644 --- a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionSettingsTests.cs +++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionSettingsTests.cs @@ -40,7 +40,7 @@ public void ShouldUseStandardizedDefaults() Assert.Equal(TimeSpan.FromSeconds(180), settings.IdleTimeout); Assert.Equal(128, settings.MaxConnections); Assert.Equal(TimeSpan.FromSeconds(30), settings.KeepAliveTime); - Assert.Equal(64, settings.DefaultBatchSize); + Assert.Equal(64, settings.BatchSize); Assert.Equal(Compression.Deflate, settings.Compression); Assert.False(settings.SkipCertificateValidation); Assert.Null(settings.Ssl); @@ -83,83 +83,43 @@ public void ShouldAllowSettingSslOptions() } [Fact] - public void ObsoleteAliasesShouldMapToCanonicalProperties() + public void MillisOptionsShouldSetTheTimeSpanProperties() { -#pragma warning disable 618 var settings = new ConnectionSettings { - MaxConnectionsPerServer = 42, - ConnectionTimeout = TimeSpan.FromSeconds(7), - IdleConnectionTimeout = TimeSpan.FromSeconds(99), - KeepAliveInterval = TimeSpan.FromSeconds(11), + ConnectTimeoutMillis = 2000, + IdleTimeoutMillis = 60000, + KeepAliveTimeMillis = 15000, + ReadTimeoutMillis = 30000 }; - // Reading the obsolete alias returns the canonical value. - Assert.Equal(42, settings.MaxConnectionsPerServer); - Assert.Equal(TimeSpan.FromSeconds(7), settings.ConnectionTimeout); - Assert.Equal(TimeSpan.FromSeconds(99), settings.IdleConnectionTimeout); - Assert.Equal(TimeSpan.FromSeconds(11), settings.KeepAliveInterval); -#pragma warning restore 618 - - // The canonical property reflects the same backing value. - Assert.Equal(42, settings.MaxConnections); - Assert.Equal(TimeSpan.FromSeconds(7), settings.ConnectTimeout); - Assert.Equal(TimeSpan.FromSeconds(99), settings.IdleTimeout); - Assert.Equal(TimeSpan.FromSeconds(11), settings.KeepAliveTime); + Assert.Equal(TimeSpan.FromMilliseconds(2000), settings.ConnectTimeout); + Assert.Equal(TimeSpan.FromMilliseconds(60000), settings.IdleTimeout); + Assert.Equal(TimeSpan.FromMilliseconds(15000), settings.KeepAliveTime); + Assert.Equal(TimeSpan.FromMilliseconds(30000), settings.ReadTimeout); } [Fact] - public void ObsoleteAliasesShouldReflectCanonicalWrites() + public void MillisOptionsShouldReflectTheTimeSpanProperties() { var settings = new ConnectionSettings { - MaxConnections = 256, - ConnectTimeout = TimeSpan.FromSeconds(3), - IdleTimeout = TimeSpan.FromSeconds(60), - KeepAliveTime = TimeSpan.FromSeconds(15), + ConnectTimeout = TimeSpan.FromSeconds(2), + ReadTimeout = TimeSpan.FromSeconds(30) }; -#pragma warning disable 618 - Assert.Equal(256, settings.MaxConnectionsPerServer); - Assert.Equal(TimeSpan.FromSeconds(3), settings.ConnectionTimeout); - Assert.Equal(TimeSpan.FromSeconds(60), settings.IdleConnectionTimeout); - Assert.Equal(TimeSpan.FromSeconds(15), settings.KeepAliveInterval); -#pragma warning restore 618 + Assert.Equal(2000, settings.ConnectTimeoutMillis); + Assert.Equal(30000, settings.ReadTimeoutMillis); } [Fact] - public void ObsoleteEnableCompressionShouldMapToCompression() + public void ReadTimeoutMillisZeroShouldDisableTheReadTimeout() { - var settings = new ConnectionSettings(); - -#pragma warning disable 618 - // Default is Deflate, so the bool shim reads true. - Assert.True(settings.EnableCompression); - - settings.EnableCompression = false; - Assert.Equal(Compression.None, settings.Compression); - Assert.False(settings.EnableCompression); + var settings = new ConnectionSettings { ReadTimeoutMillis = 0 }; - settings.EnableCompression = true; - Assert.Equal(Compression.Deflate, settings.Compression); - Assert.True(settings.EnableCompression); -#pragma warning restore 618 + Assert.Equal(Timeout.InfiniteTimeSpan, settings.ReadTimeout); + Assert.Equal(0, settings.ReadTimeoutMillis); } - [Fact] - public void ObsoleteEnableCompressionShouldReflectCanonicalCompression() - { - var settings = new ConnectionSettings { Compression = Compression.None }; - -#pragma warning disable 618 - Assert.False(settings.EnableCompression); -#pragma warning restore 618 - - settings.Compression = Compression.Deflate; - -#pragma warning disable 618 - Assert.True(settings.EnableCompression); -#pragma warning restore 618 - } } } diff --git a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionTests.cs b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionTests.cs index f488ad632cd..7a3bee9e629 100644 --- a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionTests.cs +++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionTests.cs @@ -1067,7 +1067,7 @@ public async Task ShouldFillBatchSizeFromDefaultWhenUnset() { var (httpClient, handler) = CreateMockHttpClient(); var serializer = CreateMockSerializer(); - var settings = new ConnectionSettings { DefaultBatchSize = 42 }; + var settings = new ConnectionSettings { BatchSize = 42 }; using var connection = new Connection(TestUri, serializer, settings, httpClient); var request = CreateTestRequest(); @@ -1084,7 +1084,7 @@ public async Task ShouldNotOverrideExplicitBatchSize() { var (httpClient, handler) = CreateMockHttpClient(); var serializer = CreateMockSerializer(); - var settings = new ConnectionSettings { DefaultBatchSize = 42 }; + var settings = new ConnectionSettings { BatchSize = 42 }; using var connection = new Connection(TestUri, serializer, settings, httpClient); var request = RequestMessage.Build("g.V()").AddG("g").AddBatchSize(100).Create(); @@ -1115,7 +1115,7 @@ public async Task ShouldNotPersistDefaultBatchSizeAcrossResubmissions() { var (httpClient, handler) = CreateMockHttpClient(); var serializer = CreateMockSerializer(); - var settings = new ConnectionSettings { DefaultBatchSize = 42 }; + var settings = new ConnectionSettings { BatchSize = 42 }; using var connection = new Connection(TestUri, serializer, settings, httpClient); // Resubmitting the same message must not carry over a previously injected default. From 770f94f6609d21b2d47595778d4a198d66e30422 Mon Sep 17 00:00:00 2001 From: Guian Gumpac Date: Tue, 23 Jun 2026 21:46:54 -0700 Subject: [PATCH 3/4] Updated doc and comment --- docs/src/upgrade/release-4.x.x.asciidoc | 4 ++-- gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs | 3 --- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/src/upgrade/release-4.x.x.asciidoc b/docs/src/upgrade/release-4.x.x.asciidoc index f10e7e4aa00..1d2d2e96295 100644 --- a/docs/src/upgrade/release-4.x.x.asciidoc +++ b/docs/src/upgrade/release-4.x.x.asciidoc @@ -56,8 +56,8 @@ Behavior changes. These change runtime behavior on upgrade, even if you do not c enables `SO_KEEPALIVE` and sets the per-socket idle time on Windows, Linux, and macOS; on other platforms keep-alive stays enabled at the OS default idle time. - `Compression` is now a `{None, Deflate}` enum defaulting to `Deflate` (compression on by default), so the driver -sends `Accept-Encoding: deflate` by default. Set `Compression.None` to disable. The deprecated `EnableCompression` -`bool` is preserved only as a compatibility shim mapping `true`/`false` to `Compression.Deflate`/`Compression.None`. +sends `Accept-Encoding: deflate` by default. Set `Compression.None` to disable. The old `EnableCompression` `bool` +has been removed. New options: diff --git a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs index a31b23f0253..76c9910d7f5 100644 --- a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs +++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs @@ -241,8 +241,5 @@ public async Task SigV4AuthShouldThrowWhenBodyIsNotByteArray() Assert.Contains("byte[]", ex.Message); } - - // --- Deprecated alias tests --- - } } From 98585bedf280605cf69a65a24c3f15d7be68dc73 Mon Sep 17 00:00:00 2001 From: Guian Gumpac Date: Wed, 24 Jun 2026 15:40:08 -0700 Subject: [PATCH 4/4] Added note in docs for "total" timeout --- docs/src/reference/gremlin-variants.asciidoc | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/src/reference/gremlin-variants.asciidoc b/docs/src/reference/gremlin-variants.asciidoc index 3419e99883d..941e2e83ddc 100644 --- a/docs/src/reference/gremlin-variants.asciidoc +++ b/docs/src/reference/gremlin-variants.asciidoc @@ -2675,6 +2675,19 @@ link:https://tinkerpop.apache.org/docs/x.y.z/dev/provider/#_graph_driver_provide |SkipCertificateValidation |Whether to skip SSL certificate validation. Only use for testing with self-signed certificates. When `Ssl` is also provided, the accept-all callback is set on an internal copy so the supplied options object is never mutated. |false |========================================================= +Note that no driver timeout bounds the *total* duration of a request once it is under way. `ReadTimeout` only bounds +the gap between response chunks, so a response that keeps producing chunks will not time out no matter how long it +runs overall, and there is no client-side "overall" request timeout. If you need an absolute deadline, impose it in +your application by passing a cancellation token that cancels after the deadline: + +[source,csharp] +---- +// bound the entire request (submit plus full result iteration) to 30 seconds +using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); +var results = await client.SubmitAsync( + "g.V().out().out()", cancellationToken: cts.Token); +---- + ==== GremlinClient Settings The following options can be passed to the `GremlinClient` constructor: