diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc index c77c4062b9b..1d42e1d8640 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -25,60 +25,20 @@ 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` (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 `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`. +* Standardized connection options across all GLVs (Java, Python, .NET, Go, JavaScript) per the TinkerPop 4.x GLV proposal; see the upgrade docs for the full per-driver table. *(breaking)* +** Aligned option names across drivers: `maxConnections` (128), `connectTimeoutMillis` (5000), `readTimeoutMillis` (off), `idleTimeoutMillis` (180000), `keepAliveTimeMillis` (30000), `compression` (on/`deflate`), `batchSize` (64), `bulkResults` (false), `maxResponseHeaderBytes`, `proxy`, and `ssl`. Timeouts use a millisecond-suffixed canonical name with an idiomatic duration companion (Java `Duration`, Go `time.Duration`, .NET `TimeSpan`, Python seconds); JavaScript uses milliseconds only. +** Java: renamed `maxConnectionPoolSize`/`connectionSetupTimeoutMillis`/`idleConnectionTimeoutMillis`/`resultIterationBatchSize` and `RequestOptions.addG`->`traversalSource`; removed `maxResponseContentLength` (responses now stream); added `readTimeoutMillis`, `keepAliveTimeMillis`, `maxResponseHeaderBytes`, `proxy`, `url(String)`, `ssl(SslContext)`. *(breaking)* +** Python: renamed `pool_size`->`max_connections` (8->128) and `ssl_options`->`ssl`; removed `headers` (use interceptors) and `max_content_length`; added the timeout, `compression`, `batch_size`, `proxy`, and `trust_env` options; requires `aiohttp>=3.11`. *(breaking)* +** .NET: renamed `MaxConnectionsPerServer`/`ConnectionTimeout`/`IdleConnectionTimeout`/`KeepAliveInterval`/`EnableCompression` and `Auth.BasicAuth`/`Auth.SigV4Auth`->`Auth.Basic`/`Auth.Sigv4`; `KeepAliveTime` now uses a real TCP keep-alive socket option; added `ReadTimeout`, `MaxResponseHeaderBytes`, `Proxy`, `Ssl`, `BulkResults`. *(breaking)* +** Go: renamed `MaximumConcurrentConnections`/`IdleConnectionTimeout`/`KeepAliveInterval`/`ConnectionTimeout`/`TlsConfig`/`RequestInterceptors`/`EnableCompression`; moved auth helpers into an `auth` sub-package (`auth.Basic`/`auth.SigV4`/`auth.SigV4WithCredentials`); added `ReadTimeout`, `MaxResponseHeaderBytes`, `Proxy`, `BatchSize`, `BulkResults`. *(breaking)* +** JavaScript: adopted `undici` for the default dispatcher; added `readTimeoutMillis`, `keepAliveTimeMillis`, `maxResponseHeaderBytes`, `proxy`, `compression`, `batchSize`, `bulkResults`, `logger`; removed `headers` (use interceptors) and `ca`/`cert`/`pfx`/`rejectUnauthorized`/`agent` (TLS via the Node/undici runtime); undici is swapped out in browser bundles. *(breaking)* +** All drivers: compression now defaults on; `connectTimeout` lowered to 5s and applied to transport establishment; `readTimeout` is a streaming-safe idle-read timeout (off by default); `idleTimeout` reaps only pooled connections. +* Fixed `gremlin-javascript` `Client.submit()` so that an explicit `bulkResults: false` request option is forwarded to the server instead of being silently dropped. * 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). -** Added `connect_timeout` (default 5s) and rewired the existing `read_timeout` into a single aiohttp `ClientTimeout` (`sock_connect`/`sock_read`); the socket-level knobs are used rather than a whole-request `total` so long but legitimate streaming responses are not aborted, while a stalled server no longer hangs forever. -** Added `idle_timeout` (default 180s) mapped to the aiohttp `TCPConnector` keep-alive timeout. -** Added `keep_alive_time` (default 30s) enabling TCP keep-alive probes via the connector socket factory (`TCP_KEEPIDLE`/`TCP_KEEPALIVE` guarded by platform availability); this required raising the minimum `aiohttp` to `3.11` (the `socket_factory` floor). *(breaking)* -** Each timeout option is named with a millisecond suffix as the primary form (`connect_timeout_millis`, `idle_timeout_millis`, `read_timeout_millis`, `keep_alive_time_millis`); the unsuffixed canonical name (`connect_timeout`, etc.) accepts the idiomatic seconds value. Both resolve to seconds internally for aiohttp. -** Added `compression` accepting `'none'`/`'deflate'`, default `'deflate'` (on), advertising `Accept-Encoding: deflate` by default; when set to `'none'`, aiohttp's automatic `Accept-Encoding` injection is suppressed so compression is not silently negotiated. -** Added `batch_size` (default 64) connection-level default that fills the per-request `batchSize` when unset. -** Added explicit HTTP `proxy` and `trust_env` options surfaced on the aiohttp `ClientSession`. -** Added a credentials-provider variant to `auth.sigv4` that accepts an optional credentials provider or callable, falling back to the AWS environment variables. -** Removed the `max_content_length` kwarg from the `gremlin-python` driver (previously accepted but discarded). -** Removed the standalone `headers` kwarg from `gremlin-python` `Client`/`DriverRemoteConnection`; custom headers must now be set via interceptors. -** Fixed `gremlin-python` `Client.submit`/`submit_async` mutating a caller-supplied `RequestMessage` in place; the message fields are now cloned before applying `request_options`/`batch_size`, so resubmitting the same message no longer accumulates state, matching the no-mutate contract of the .NET/JS drivers. -** Fixed `gremlin-python` `Client.submit`/`submit_async` mutating a caller-supplied `RequestMessage` in place; the message fields are now cloned before applying `request_options`/`default_batch_size`, so resubmitting the same message no longer accumulates state, matching the no-mutate contract of the .NET/JS drivers. -* Standardized `gremlin-driver` (Java) connection options -** Renamed the `connectionSetupTimeoutMillis` builder option to `connectTimeout` (default lowered from 15s to 5s) and actually wired it to `ChannelOption.CONNECT_TIMEOUT_MILLIS` on the connection bootstrap; it previously was validated but never applied. The old name is removed. Settable as `connectTimeout(Duration)` or `connectTimeoutMillis(int)` (YAML key `connectionPool.connectTimeoutMillis`). *(breaking)* -** Renamed `maxConnectionPoolSize` to `maxConnections` (default 128), `idleConnectionTimeoutMillis` to `idleTimeout` (default 180s), and `resultIterationBatchSize` to `batchSize` (default 64; the connection-level default that fills a request's per-request `batchSize` when unset, mirroring how `bulkResults` shares one name across scopes); the old builder methods, accessors, and YAML keys are removed. *(breaking)* -** Added `readTimeout` (default 0/off), a per-request idle-read timeout armed when a request is written and reset on each inbound response chunk via a new `ReadTimeoutHandler` inserted after the HTTP codec, so it never fires while a pooled connection is idle between requests. Settable as `readTimeout(Duration)` or `readTimeoutMillis(long)` (YAML key `connectionPool.readTimeoutMillis`). -** Scoped `idleTimeout` to pooled connections only: an idle event that fires while a request is in flight is now ignored, so `idleTimeout` reaps only connections idle in the pool and no longer overlaps `readTimeout` on the in-flight window. A stalled in-flight response is bounded solely by `readTimeout`. Settable as `idleTimeout(Duration)` or `idleTimeoutMillis(long)` (YAML key `connectionPool.idleTimeoutMillis`). *(breaking)* -** Renamed `RequestOptions.Builder.addG` to `traversalSource`; the old name is removed. *(breaking)* -** Added `keepAliveTime` (default 30s/30000ms), the idle time before TCP keep-alive probes begin, enabling `ChannelOption.SO_KEEPALIVE` on the connection bootstrap and configuring the per-socket idle time via the JDK `TCP_KEEPIDLE` extended socket option where supported (JDK 11+ on Linux/macOS); it is a no-op fallback to the OS default idle time on platforms/JDKs where that option is unavailable. Set to `0` to disable. Settable as `keepAliveTime(Duration)` or `keepAliveTimeMillis(long)` (YAML key `connectionPool.keepAliveTimeMillis`). -** Added a `url(String)` builder method that configures the endpoint from a single URL, deriving SSL from the scheme (`https` enables, `http` disables), the contact point from the host, and the port and path when present; `addContactPoint(s)`/`port`/`path` remain for multi-host configurations. -** Added `compression`, a `{NONE, DEFLATE}` enum defaulting to `DEFLATE` (compression on by default; set `NONE` to disable). `Accept-Encoding: deflate` is sent whenever `compression == DEFLATE`. -** Added `maxResponseHeaderBytes` (default 8192) exposing the Netty `HttpClientCodec` max header size, previously hardcoded. -** Added an `ssl(SslContext)` builder accepting a fully configured Netty `SslContext`. -** Added a `proxy(ProxyOptions)` builder that inserts a Netty `HttpProxyHandler` into the pipeline before the SSL handler. -** Removed the `maxResponseContentLength` connection option and its `HttpObjectAggregator` cap; responses are streamed and the new `readTimeout` is the partial mitigation. -** Reconciled the `validationRequest` default: the builder default is now `g.inject(0)` to match the `Settings` default (it was previously `''`). * Removed `Transaction.open()` in favor of `begin()`, which is now the single transaction-start primitive across embedded and remote contexts. * Changed `begin()` and `close()` to be idempotent and calling it when a transaction is already in that state no longer throws. * Added configurable CORS `allowedOrigins` setting to Gremlin Server; warns when wildcard origin is used alongside authentication. -* Standardized `gremlin-go` connection options per the TinkerPop 4.x GLV proposal: -** Moved `BasicAuth`/`SigV4Auth`/`SigV4AuthWithCredentials` out of package `gremlingo` into a new `auth` sub-package as `auth.Basic`/`auth.SigV4`/`auth.SigV4WithCredentials`. The flat `gremlingo` functions have been removed; use the `auth` sub-package. -** Renamed `MaximumConcurrentConnections` to `MaxConnections` (default 128), `IdleConnectionTimeout` to `IdleTimeoutMillis` (default 180000), `KeepAliveInterval` to `KeepAliveTimeMillis` (default 30000), `ConnectionTimeout` to `ConnectTimeoutMillis` (default 5000), `TlsConfig` to `Ssl` (`*tls.Config`), and `RequestInterceptors` to `Interceptors`. Each timeout has a `time.Duration` companion (`IdleTimeout`/`KeepAliveTime`/`ConnectTimeout`); set only one form per option. *(breaking)* -** Renamed `EnableCompression` to `Compression`, now a typed `Compression` const (`CompressionNone`/`CompressionDeflate`) defaulting to `CompressionDeflate` (compression on by default; set `CompressionNone` to disable); `Accept-Encoding: deflate` is sent by default and the manual per-chunk deflate decode path is retained. *(breaking)* -** Added `BatchSize` (default 64), a connection-level default that fills the per-request `batchSize` when unset. -** Added `MaxResponseHeaderBytes`, exposing `http.Transport.MaxResponseHeaderBytes`. -** Added `Proxy` and set `http.Transport.Proxy` to `http.ProxyFromEnvironment` by default, fixing a regression where a custom transport silently dropped environment proxy configuration. -** Added `ReadTimeoutMillis` (with a `time.Duration` companion `ReadTimeout`; set only one), an idle-read timeout reset on each read (via `SetReadDeadline`) that is re-armed across pooled-connection reuse and does not set `http.Client.Timeout`. -** Added `BulkResults` (default false), a connection-level default for `bulkResults` applied to every request unless overridden per-request, aligning gremlin-go with the other GLVs which already expose a connection-level setting; the `DriverRemoteConnection` traversal path defaults to `true` regardless of this setting. * Fixed `ByteBuf` leak in `GraphBinaryMessageSerializerV4` when serialization throws an `IOException`. * Changed `Tree` to no longer extend `HashMap`; it is now a final class with a tree-shaped API (`childAt`, `hasChild`, `contains`, `findSubtree`, `getOrCreateChild`, `getNodesAtDepth`, `getLeafNodes`, `nodeCount`) and is no longer a `Map`. * Changed `count(local)` on a `Tree` to return the total node count (`Tree.nodeCount()`) instead of the root-entry count. diff --git a/docs/src/reference/gremlin-variants.asciidoc b/docs/src/reference/gremlin-variants.asciidoc index 941e2e83ddc..33e9f990ff8 100644 --- a/docs/src/reference/gremlin-variants.asciidoc +++ b/docs/src/reference/gremlin-variants.asciidoc @@ -1984,18 +1984,42 @@ can be passed in the constructor of a new `Client` or `DriverRemoteConnection` : |Key |Type |Description |Default |url |String |The resource uri. |None |options |Object |The connection options. |{} -|options.traversalSource |String |The traversal source. |'g' -|options.headers |Object |Additional HTTP header key/values included with each request. |undefined -|options.interceptors |RequestInterceptor/RequestInterceptor[] |One or more functions that can modify the HTTP request before it is sent. |undefined +|options.traversalSource |String |The name of the remote `GraphTraversalSource`. |'g' +|options.maxConnections |Number |Caps the number of concurrent connections per origin on the default dispatcher. |128 +|options.readTimeoutMillis |Number |A per-read idle timeout in milliseconds (undici `bodyTimeout`). It resets per chunk, so it is safe for streaming. |runtime default +|options.maxResponseHeaderBytes |Number |The maximum size of the response headers in bytes (undici `maxHeaderSize`). |runtime default +|options.keepAliveTimeMillis |Number |Idle time in milliseconds before TCP keep-alive probes begin. Enables `SO_KEEPALIVE` on the socket. Set to `0` to disable. |30000 +|options.proxy |String |An HTTP proxy URI. When set, requests are routed through an undici `ProxyAgent`. |undefined +|options.compression |String |Response compression codec, `'none'` or `'deflate'`. |'deflate' +|options.batchSize |Number |Connection-level default that fills a request's `batchSize` when it is left unset. |64 +|options.bulkResults |Boolean |Connection-level default for `bulkResults`, applied to every request unless overridden per-request. The `DriverRemoteConnection` traversal path defaults to `true` regardless of this setting. |false +|options.logger |Object/Function |A logger object (with `debug`/`info`/`warn`/`error` methods, e.g. `console`) or a `(level, message, ...args)` callback. |none (disabled) +|options.interceptors |RequestInterceptor/RequestInterceptor[] |One or more functions that can modify the HTTP request before it is sent, run in order. |undefined |options.auth |RequestInterceptor |An auth interceptor that is always appended to the end of the interceptor list so it runs last. |undefined |options.preciseNumbers |Boolean |When `true`, wraps deserialized numbers in typed wrappers that preserve the server's original type. |undefined |options.reader |GraphBinaryReader |The reader to use for deserializing responses. |GraphBinaryReader -|options.writer |GraphBinaryWriter |The writer to use for serializing requests. |GraphBinaryWriter |options.enableUserAgentOnConnect |Boolean |Determines if a user agent header will be sent with requests. |true -|options.agent |Agent |A custom `node:http` or `node:https` Agent for connection pooling or proxy configuration. |undefined |options.pdtRegistry |ProviderDefinedTypeRegistry |A registry for hydrating and dehydrating <>. |undefined |========================================================= +TLS is configured through the Node.js/undici runtime (for example the `NODE_EXTRA_CA_CERTS` or +`NODE_TLS_REJECT_UNAUTHORIZED` environment variables), not through driver options. Custom headers are set via an +interceptor rather than a `headers` option, for example `interceptors: (req) => { req.headers['X-Custom'] = 'value'; }`. + +Note that no driver timeout bounds the *total* duration of a request once it is under way. `readTimeoutMillis` 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 around the call by racing the `submit` promise against a timeout: + +[source,javascript] +---- +// bound the entire request to 30 seconds +const results = await Promise.race([ + client.submit('g.V().out().out()'), + new Promise((_, reject) => setTimeout(() => reject(new Error('request timed out')), 30000)), +]); +---- + [[gremlin-javascript-interceptors]] === RequestInterceptor diff --git a/docs/src/upgrade/release-4.x.x.asciidoc b/docs/src/upgrade/release-4.x.x.asciidoc index 0c1a3ca37d4..e2edc0bd98e 100644 --- a/docs/src/upgrade/release-4.x.x.asciidoc +++ b/docs/src/upgrade/release-4.x.x.asciidoc @@ -32,208 +32,80 @@ 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. 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 (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`. -- `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 old `EnableCompression` `bool` -has been removed. - -New options: - -- `Ssl` (an `SslClientAuthenticationOptions` for client certificates and custom CAs; `SkipCertificateValidation` is -applied to an internal copy rather than mutating the supplied options). -- `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`. +==== Standardizing GLV Connection Options + +TinkerPop 4.x standardizes connection option names and defaults across all five Gremlin Language Variants (Java, Python, +.NET, Go, and JavaScript). Each driver using its language-idiomatic casing (`camelCase`, `PascalCase`, or `snake_case`). +Renames are breaking: the old option names have been removed (no deprecated aliases), so existing code must be updated. + +NOTE: Timeouts use a millisecond-suffixed canonical name (`connectTimeoutMillis`, `readTimeoutMillis`, +`idleTimeoutMillis`, `keepAliveTimeMillis`, and the `_millis` form in Python). Java, Go, .NET, and Python also accept an +idiomatic duration companion for the same setting (Java `Duration`, Go `time.Duration`, .NET `TimeSpan`, Python seconds); +set only one form per option. JavaScript exposes only the millisecond form. + +===== Standardized options (cross-GLV) + +The table lists each standardized option by driver. Defaults are shown in parentheses; "n/a" means the driver does not +expose that option. + +[width="100%",cols="2,2,2,2,2,2",options="header"] +|========================================================= +|Concept |Java (`Cluster.Builder`) |Python (kwarg) |.NET (`ConnectionSettings`) |Go (settings field) |JavaScript (option) +|Max pooled connections |`maxConnections` (128) |`max_connections` (128) |`MaxConnections` (128) |`MaxConnections` (128) |`maxConnections` (128) +|Connect timeout |`connectTimeoutMillis` (5000) |`connect_timeout_millis` (5000) |`ConnectTimeoutMillis` (5000) |`ConnectTimeoutMillis` (5000) |n/a +|Idle-read timeout |`readTimeoutMillis` (0/off) |`read_timeout_millis` (off) |`ReadTimeoutMillis` (0/off) |`ReadTimeoutMillis` (0/off) |`readTimeoutMillis` (undici default) +|Pool idle timeout |`idleTimeoutMillis` (180000) |`idle_timeout_millis` (180000) |`IdleTimeout` (180s) |`IdleTimeoutMillis` (180000) |n/a +|TCP keep-alive idle |`keepAliveTimeMillis` (30000) |`keep_alive_time_millis` (30000) |`KeepAliveTime` (30s) |`KeepAliveTimeMillis` (30000) |`keepAliveTimeMillis` (30000) +|Compression |`compression` (`DEFLATE`) |`compression` (`'deflate'`) |`Compression` (`Deflate`) |`Compression` (`CompressionDeflate`) |`compression` (`'deflate'`) +|Connection-level batch size |`batchSize` (64) |`batch_size` (64) |`BatchSize` (64) |`BatchSize` (64) |`batchSize` (64) +|Connection-level bulkResults |`bulkResults` (false) |`bulk_results` (false) |`BulkResults` (false) |`BulkResults` (false) |`bulkResults` (false) +|Max response header bytes |`maxResponseHeaderBytes` (8192) |n/a |`MaxResponseHeaderBytes` (handler default) |`MaxResponseHeaderBytes` (net/http default) |`maxResponseHeaderBytes` (undici default) +|TLS configuration |`ssl(SslContext)` + keystore builders |`ssl` (`SSLContext`) |`Ssl` (`SslClientAuthenticationOptions`) |`Ssl` (`*tls.Config`) |runtime (`NODE_EXTRA_CA_CERTS`, etc.) +|HTTP proxy |`proxy(ProxyOptions)` |`proxy` |`Proxy` (`IWebProxy`) |`Proxy` (env default) |`proxy` (undici `ProxyAgent`) +|Request interceptors |`interceptors` |`interceptors` |interceptor delegates |`Interceptors` |`interceptors` +|========================================================= + +===== Behavior changes (all drivers) + +These change runtime behavior on upgrade even if you do not change your configuration: + +- *Compression defaults to on.* Every driver now defaults compression to `deflate` and sends `Accept-Encoding: deflate`. + Disable it with the compression option's "none" value (`Compression.NONE`, `'none'`, `Compression.None`, + `CompressionNone`). +- *Connect timeout lowered to 5s* (from 15s, where applicable) and is now actually applied to transport establishment + (TCP connect plus TLS handshake), not the whole request. +- *`readTimeout` is an idle-read timeout*, reset on each inbound response chunk, so it is streaming-safe and bounds only + the gap between chunks, not total response duration. It is off by default. +- *`idleTimeout` reaps only pooled connections* that are idle between requests; it no longer bounds an in-flight + response (that is `readTimeout`'s job). + +===== Driver-specific notes + +- *Java* (`gremlin-driver`): renamed `maxConnectionPoolSize`->`maxConnections`, `connectionSetupTimeoutMillis`->`connectTimeoutMillis`, + `idleConnectionTimeoutMillis`->`idleTimeoutMillis`, `resultIterationBatchSize`->`batchSize`, and `RequestOptions` `addG`->`traversalSource`; + old names are removed. New: `readTimeoutMillis`, `keepAliveTimeMillis`, `maxResponseHeaderBytes`, `proxy(ProxyOptions)`, + `url(String)`, `ssl(SslContext)`. Removed `maxResponseContentLength`. `validationRequest` default reconciled to `g.inject(0)`. +- *Python* (`gremlin-python`): renamed `pool_size`->`max_connections` (default 8->128) and `ssl_options`->`ssl` (old names removed). + New: `connect_timeout_millis`, `read_timeout_millis`, `idle_timeout_millis`, `keep_alive_time_millis`, `compression`, + `batch_size`, `proxy`, `trust_env`. `auth.sigv4` gained an optional credentials provider. Removed `headers` (use interceptors) + and `max_content_length`. Requires `aiohttp>=3.11`. +- *.NET* (`gremlin-dotnet`): renamed `MaxConnectionsPerServer`->`MaxConnections`, `ConnectionTimeout`->`ConnectTimeout`, + `IdleConnectionTimeout`->`IdleTimeout`, `KeepAliveInterval`->`KeepAliveTime`, `EnableCompression`->`Compression`, and + `Auth.BasicAuth`/`Auth.SigV4Auth`->`Auth.Basic`/`Auth.Sigv4`; old names removed. New: `ReadTimeout`, `MaxResponseHeaderBytes`, + `Proxy`, `Ssl`, `BulkResults`. `KeepAliveTime` now drives a real TCP keep-alive socket option instead of the inert HTTP/2 ping. +- *Go* (`gremlin-go`): renamed `MaximumConcurrentConnections`->`MaxConnections`, `IdleConnectionTimeout`->`IdleTimeout`, + `KeepAliveInterval`->`KeepAliveTime`, `ConnectionTimeout`->`ConnectTimeout`, `TlsConfig`->`Ssl`, `RequestInterceptors`->`Interceptors`, + `EnableCompression`->`Compression`; old struct fields removed. Auth helpers moved from package `gremlingo` + (`BasicAuth`/`SigV4Auth`/`SigV4AuthWithCredentials`) into a new `auth` sub-package (`auth.Basic`/`auth.SigV4`/`auth.SigV4WithCredentials`). + New: `ReadTimeout(Millis)`, `MaxResponseHeaderBytes`, `Proxy` (defaults to `http.ProxyFromEnvironment`), `BatchSize`, `BulkResults`. +- *JavaScript* (`gremlin-javascript`): adopted `undici` as a pinned dependency providing the default dispatcher built from the + options above. New: `readTimeoutMillis`, `keepAliveTimeMillis`, `maxResponseHeaderBytes`, `proxy`, `compression`, `batchSize`, + `bulkResults`, `logger`. Removed `headers` (use interceptors) and the `ca`/`cert`/`pfx`/`rejectUnauthorized`/`agent` options + (TLS is configured through the Node/undici runtime). undici is swapped out in browser bundles, where these connection-pool + options are managed by the browser. See: link:https://lists.apache.org/thread/yqtr2wnb1kq2pqqq4002cz511q5o0bkg[[DISCUSS] Standardizing GLV connection options in TinkerPop 4]. -==== Standardizing Go Connection Options - -TinkerPop 4.x standardizes connection option names and defaults across the GLVs. In `gremlin-go`, several -`DriverRemoteConnectionSettings`/`ClientSettings` options introduced for the HTTP driver in 4.0.0-beta.2 have been -renamed for consistency, the authentication helpers have moved into a dedicated `auth` sub-package, and a number of -new options have been added. The notes below describe the Go changes. See <> -for the equivalent changes in the other drivers. - -Renames (breaking). The following settings fields have been renamed. Because they are struct fields, they cannot be -aliased, so existing code must be updated to the new names: - -- `MaximumConcurrentConnections` is now `MaxConnections` (default 128). -- `IdleConnectionTimeout` is now `IdleTimeoutMillis` (default 180000), an `int` of milliseconds. The `time.Duration` companion `IdleTimeout` may be set instead (set only one). -- `KeepAliveInterval` is now `KeepAliveTimeMillis` (default 30000), an `int` of milliseconds. The `time.Duration` companion `KeepAliveTime` may be set instead (set only one). -- `ConnectionTimeout` is now `ConnectTimeoutMillis` (default 5000), an `int` of milliseconds. The `time.Duration` companion `ConnectTimeout` may be set instead (set only one). -- `TlsConfig` (`*tls.Config`) is now `Ssl`. -- `RequestInterceptors` is now `Interceptors`. -- `EnableCompression` is now `Compression`. -- The `BasicAuth`, `SigV4Auth`, and `SigV4AuthWithCredentials` functions have moved out of package `gremlingo` into a -new `auth` sub-package (`github.com/apache/tinkerpop/gremlin-go/v4/driver/auth`) as `auth.Basic`, `auth.SigV4`, and -`auth.SigV4WithCredentials`. The flat `gremlingo` functions have been removed; use the `auth` sub-package. - -Behavior changes. These change runtime behavior on upgrade, even if you do not change your configuration: - -- `Compression` is now a typed `Compression` const (`gremlingo.CompressionNone`/`gremlingo.CompressionDeflate`) -defaulting to `CompressionDeflate` (compression on by default), so the driver sends `Accept-Encoding: deflate` by -default. Set `gremlingo.CompressionNone` to disable it. The manual per-chunk deflate decode path is retained. -- `http.Transport.Proxy` now defaults to `http.ProxyFromEnvironment`, so the standard `HTTP_PROXY`/`HTTPS_PROXY`/ -`NO_PROXY` environment variables are honored. Previously the custom transport left `Proxy` unset, silently dropping -any environment proxy configuration. - -New options: - -- `ReadTimeoutMillis` (default 0, disabled), an `int` of milliseconds: a per-read idle timeout reset on each read of -the response body and re-armed across pooled-connection reuse, so it never fires while a pooled connection is idle -between requests. The `time.Duration` companion `ReadTimeout` may be set instead (set only one). -- `MaxResponseHeaderBytes`: exposes `http.Transport.MaxResponseHeaderBytes` (native bytes). -- `Proxy`: an explicit `func(*http.Request) (*url.URL, error)` proxy override for the transport. -- `BatchSize` (default 64): a connection-level default that fills a request's `batchSize` when it is left -unset. -- `BulkResults` (default false): a connection-level default for `bulkResults` applied to every request unless -overridden per-request. The `DriverRemoteConnection` traversal path defaults to `true` regardless of this setting. - -See: link:https://lists.apache.org/thread/yqtr2wnb1kq2pqqq4002cz511q5o0bkg[[DISCUSS] Standardizing GLV connection options in TinkerPop 4]. - -==== Standardizing Python Connection Options - -TinkerPop 4.x standardizes connection option names and defaults across the GLVs. In `gremlin-python`, several -connection options passed to `Client`/`DriverRemoteConnection` 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 Python changes. See <> for the equivalent changes in -the other drivers. - -Renames (breaking). The following options have been renamed and the old names removed. Migrate to the new names: - -- `pool_size` is now `max_connections` (default raised from 8 to 128). `max_connections` is also applied to the - aiohttp `TCPConnector` `limit`, so the transport layer reflects the option in addition to sizing the connection - pool. -- `ssl_options` is now `ssl` (accepts an `ssl.SSLContext`). - -Behavior changes. These change runtime behavior on upgrade, even if you do not change your configuration: - -- `compression` is a new option that defaults to `'deflate'` (on), so the driver advertises `Accept-Encoding: deflate` - by default. Set `compression='none'` to disable it; when disabled, aiohttp's automatic `Accept-Encoding` injection is - suppressed so compression is not silently negotiated. Defaulting compression on is a deliberate deviation from the - proposal's agreed default-off, applied consistently across the GLVs by later agreement. -- `connect_timeout` now defaults to 5s. Together with `read_timeout` it is wired into a single aiohttp `ClientTimeout` - via the socket-level `sock_connect`/`sock_read` knobs rather than a whole-request `total`, so long but legitimate - streaming responses are not aborted while a stalled server no longer hangs forever. -- The minimum supported `aiohttp` has been raised to `3.11` (required for the `socket_factory` used by - `keep_alive_time`). *(breaking)* - -New options: - -- `connect_timeout` (default 5s): a new socket-connect timeout. It is combined with the existing `read_timeout` - (rewired into a single aiohttp `ClientTimeout` via `sock_connect`/`sock_read`, as described above). - Each timeout option's primary form is the millisecond-suffixed name (`connect_timeout_millis`, `idle_timeout_millis`, - `read_timeout_millis`, `keep_alive_time_millis`); the unsuffixed canonical name (`connect_timeout`, etc.) accepts the - idiomatic seconds value. -- `idle_timeout` (default 180s): mapped to the aiohttp `TCPConnector` keep-alive timeout. -- `keep_alive_time` (default 30s): enables TCP keep-alive probes via the connector socket factory - (`TCP_KEEPIDLE`/`TCP_KEEPALIVE`, guarded by platform availability). -- `compression` (`'none'`/`'deflate'`, default `'deflate'`): the wire compression negotiated with the server. -- `batch_size` (default 64): a connection-level default that fills the per-request `batchSize` when unset. -- `proxy` and `trust_env`: explicit HTTP proxy and environment-trust options surfaced on the aiohttp `ClientSession`. -- `auth.sigv4` gains a credentials-provider variant that accepts an optional credentials provider or callable, falling - back to the AWS environment variables. - -Removals (breaking): - -- The `max_content_length` kwarg has been removed (it was previously accepted but discarded). -- The standalone `headers` kwarg has been removed from `Client`/`DriverRemoteConnection`; custom headers must now be - set via interceptors. - -See: link:https://lists.apache.org/thread/yqtr2wnb1kq2pqqq4002cz511q5o0bkg[[DISCUSS] Standardizing GLV connection options in TinkerPop 4]. - -==== Standardizing Java Connection Options - -TinkerPop 4.x standardizes connection option names and defaults across the GLVs. In the Java reference driver -(`gremlin-driver`), several `Cluster.Builder` options have been renamed for consistency. As 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 Java changes. See <> for the equivalent changes in the -other drivers. - -Renames (breaking). The following builder methods have been renamed and the old names removed; migrate to the new -names: - -- `maxConnectionPoolSize` is now `maxConnections` (default 128). -- `idleConnectionTimeoutMillis` is now `idleTimeout` (default 180000 ms), settable as `idleTimeout(Duration)` or `idleTimeoutMillis(long)` (YAML key `connectionPool.idleTimeoutMillis`). -- `connectionSetupTimeoutMillis` is now `connectTimeout`, settable as `connectTimeout(Duration)` or `connectTimeoutMillis(int)` (YAML key `connectionPool.connectTimeoutMillis`). -- `resultIterationBatchSize` is now `batchSize` (default 64); it sets the connection-level default that fills a request's per-request `batchSize` when unset (mirroring how `bulkResults` shares one name across connection and per-request scopes). -- A new `ssl(SslContext)` builder accepts a fully configured Netty `SslContext`. -- On `RequestOptions`, `addG` is now `traversalSource`. - -Behavior changes. These change runtime behavior on upgrade, even if you do not change your configuration: - -- `idleTimeout` now applies only to connections sitting idle in the pool between requests. Previously its underlying - Netty `IdleStateHandler` also watched the in-flight read window, so a stalled in-flight response could be closed - by `idleTimeout` even with `readTimeout` disabled. That in-flight coverage is now solely `readTimeout`'s job, so - if you relied on `idleTimeout` to bound a hung in-flight request, set `readTimeout` explicitly. -- `connectTimeout` now defaults to 5s (lowered from 15s) and is now actually applied to the connection bootstrap. - Previously `connectionSetupTimeoutMillis` was validated but never enforced, so the connection setup was effectively - unbounded. A slow TCP or TLS setup now fails after 5s where it previously did not time out. If you rely on the old - behavior, set `connectTimeout` explicitly to a larger value. -- `compression` now defaults to `Compression.DEFLATE` (on), so the driver sends `Accept-Encoding: deflate` by default. - This preserves the pre-4.x always-on Java behavior. Set `compression(Compression.NONE)` to disable it. -- The `validationRequest` builder default has been reconciled to `g.inject(0)` (it was previously an empty string in - the builder, which did not match the config-file default). - -New options: - -- `readTimeout` (default 0, disabled): a streaming-safe idle-read timeout that fires only if no response chunk arrives - within the interval. It is armed per request and does not affect idle pooled connections. Settable as - `readTimeout(Duration)` or `readTimeoutMillis(long)` (YAML key `connectionPool.readTimeoutMillis`). -- `keepAliveTime` (default 30000 ms): the idle time before TCP keep-alive probes begin. It enables `SO_KEEPALIVE` and - sets `TCP_KEEPIDLE` where supported (JDK 11+ on Linux and macOS). Set 0 to disable. Settable as - `keepAliveTime(Duration)` or `keepAliveTimeMillis(long)` (YAML key `connectionPool.keepAliveTimeMillis`). -- `maxResponseHeaderBytes` (default 8192): the maximum response header size, which was previously hardcoded. -- `proxy(ProxyOptions)`: routes connections through an HTTP proxy. -- `url(String)`: configures the endpoint from a single URL (scheme, host, port, and path). The existing - `addContactPoint`/`addContactPoints`, `port`, and `path` options remain for multi-host configurations. - -The `maxResponseContentLength` setting has been removed. Responses are now streamed rather than aggregated -and size-capped. The new idle `readTimeout` is the partial mitigation for unbounded or slow responses. - -Note that none of the driver timeouts bound the *total* time of a request: `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. If -you need an overall deadline, impose it in your application around the asynchronous API, for example by wrapping a -`CompletableFuture` with a timeout: - -[source,java] ----- -// bound the entire request (submit plus full result iteration) to 30 seconds -final List results = client.submitAsync("g.V().out().out()") - .thenCompose(ResultSet::all) - .orTimeout(30, TimeUnit.SECONDS) - .get(); ----- - -See: link:https://lists.apache.org/thread/yqtr2wnb1kq2pqqq4002cz511q5o0bkg[[DISCUSS] Standardizing GLV connection options in TinkerPop 4 -]. - ==== Declarative Pattern Matching Gremlin has always offered both imperative and declarative styles to writing graph queries. While the imperative style diff --git a/gremlin-examples/gremlin-javascript/connections.js b/gremlin-examples/gremlin-javascript/connections.js index 23648fb6751..fa33ad3073d 100644 --- a/gremlin-examples/gremlin-javascript/connections.js +++ b/gremlin-examples/gremlin-javascript/connections.js @@ -20,7 +20,6 @@ under the License. const gremlin = require('gremlin'); const traversal = gremlin.process.AnonymousTraversalSource.traversal; const DriverRemoteConnection = gremlin.driver.DriverRemoteConnection; -const serializer = gremlin.structure.io.graphserializer; const serverUrl = 'ws://localhost:8182/gremlin'; const vertexLabel = 'connection'; @@ -47,11 +46,13 @@ async function withRemote() { async function withConfigs() { // Connecting and customizing configurations const dc = new DriverRemoteConnection(serverUrl, { - mimeType: 'application/vnd.gremlin-v3.0+json', - reader: serializer, - writer: serializer, - rejectUnauthorized: false, traversalSource: 'g', + // Optionally tune the underlying connection pool and timeouts. + maxConnections: 64, + readTimeout: 30000, + // TLS is configured through the Node/undici runtime, not via driver + // options: e.g. set NODE_EXTRA_CA_CERTS to trust a custom CA, or + // NODE_TLS_REJECT_UNAUTHORIZED=0 to relax certificate verification. }); const g = traversal().withRemote(dc); diff --git a/gremlin-js/gremlin-javascript/lib/driver/client.ts b/gremlin-js/gremlin-javascript/lib/driver/client.ts index fbb1ac46711..b13493b208e 100644 --- a/gremlin-js/gremlin-javascript/lib/driver/client.ts +++ b/gremlin-js/gremlin-javascript/lib/driver/client.ts @@ -126,8 +126,17 @@ export default class Client { if (requestOptions?.evaluationTimeout) { requestBuilder.addTimeoutMillis(requestOptions.evaluationTimeout); } - if (requestOptions?.bulkResults) { + // Per-request value wins (including an explicit `false`); otherwise apply the + // connection-level default only when it is true. + if (requestOptions?.bulkResults !== undefined) { requestBuilder.addBulkResults(requestOptions.bulkResults); + } else if (this._connection.bulkResults) { + requestBuilder.addBulkResults(true); + } + // Fill the per-request batchSize from the connection-level default when unset. + const batchSize = requestOptions?.batchSize ?? this._connection.batchSize; + if (batchSize !== undefined) { + requestBuilder.addBatchSize(batchSize); } if (requestOptions?.transactionId) { requestBuilder.addField('transactionId', requestOptions.transactionId); diff --git a/gremlin-js/gremlin-javascript/lib/driver/connection.ts b/gremlin-js/gremlin-javascript/lib/driver/connection.ts index 1be591ca9a2..4a673d8b890 100644 --- a/gremlin-js/gremlin-javascript/lib/driver/connection.ts +++ b/gremlin-js/gremlin-javascript/lib/driver/connection.ts @@ -23,7 +23,7 @@ import { Buffer } from 'buffer'; import { EventEmitter } from 'eventemitter3'; -import type { Agent } from 'node:http'; +import type { Dispatcher } from 'undici'; import ioc, { createPreciseReader } from '../structure/io/binary/GraphBinary.js'; import GraphBinaryReader from '../structure/io/binary/internals/GraphBinaryReader.js'; import StreamReader from '../structure/io/binary/internals/StreamReader.js'; @@ -33,6 +33,8 @@ import {RequestMessage} from "./request-message.js"; import { HttpRequest, RequestInterceptor } from './http-request.js'; import ResponseError from './response-error.js'; import { Traverser } from '../process/traversal.js'; +import { buildDispatcher, httpFetch } from './dispatcher.js'; +import { Logger, LoggerCallback, normalizeLogger } from './logger.js'; const responseStatusCode = { success: 200, @@ -40,24 +42,49 @@ const responseStatusCode = { partialContent: 206, }; +/** + * Selects the content encoding requested from, and decoded for, the server. `'deflate'` (the + * default) sends an `Accept-Encoding: deflate` header and decodes deflate responses; `'none'` + * turns compression off. + */ +export type Compression = 'none' | 'deflate'; + export type ConnectionOptions = { - ca?: string[]; - cert?: string | string[] | Buffer; - pfx?: string | Buffer; preciseNumbers?: boolean; pdtRegistry?: any; reader?: any; - rejectUnauthorized?: boolean; traversalSource?: string; - headers?: Record; enableUserAgentOnConnect?: boolean; - agent?: Agent; + /** Maximum number of concurrent connections per origin. Defaults to 128. */ + maxConnections?: number; + /** Idle-read (body) timeout in milliseconds, applied to the default dispatcher. */ + readTimeoutMillis?: number; + /** Maximum size of the response headers in bytes, applied to the default dispatcher. */ + maxResponseHeaderBytes?: number; + /** + * Idle time in milliseconds before TCP keep-alive probes begin on a connection. Defaults to + * 30000 (30s) when unset. Set to `0` to disable keep-alive entirely. + */ + keepAliveTimeMillis?: number; + /** HTTP proxy URI. When set, requests are routed through an undici `ProxyAgent`. */ + proxy?: string; + /** Response compression codec. Defaults to `'deflate'` (on). */ + compression?: Compression; + /** Connection-level default that fills a request's `batchSize` when it is left unset. Defaults to 64. */ + batchSize?: number; + /** Connection-level default for `bulkResults`, applied to every request unless overridden per-request. Defaults to `false`. */ + bulkResults?: boolean; + /** An optional logger (a logger object or a callback). Logging is disabled when unset. */ + logger?: Logger; interceptors?: RequestInterceptor | RequestInterceptor[]; /** An optional auth interceptor. As a convenience, this is always appended to the end of the * interceptor list so it runs last, after any user interceptors have modified the request. */ auth?: RequestInterceptor; }; +/** The default per-request batch size used when neither the request nor the connection sets one. */ +export const DEFAULT_BATCH_SIZE = 64; + /** * Represents a single connection to a Gremlin Server. */ @@ -69,6 +96,11 @@ export default class Connection extends EventEmitter { private readonly _enableUserAgentOnConnect: boolean; private readonly _interceptors: RequestInterceptor[]; + private readonly _dispatcher: Dispatcher | undefined; + private readonly _compression: Compression; + private readonly _batchSize: number; + private readonly _bulkResults: boolean; + private readonly _log: LoggerCallback; /** * Creates a new instance of {@link Connection}. @@ -87,6 +119,29 @@ export default class Connection extends EventEmitter { } this.traversalSource = options.traversalSource || 'g'; this._enableUserAgentOnConnect = options.enableUserAgentOnConnect !== false; + this._log = normalizeLogger(options.logger); + + if (options.compression === undefined) { + this._compression = 'deflate'; + } else if (options.compression === 'none' || options.compression === 'deflate') { + this._compression = options.compression; + } else { + throw new TypeError(`compression must be 'none' or 'deflate'`); + } + + this._batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE; + this._bulkResults = options.bulkResults ?? false; + + // The driver builds and owns the undici dispatcher from the discrete options. + // TLS is configured through the Node/undici runtime (e.g. NODE_EXTRA_CA_CERTS), + // not through a driver option. + this._dispatcher = buildDispatcher({ + maxConnections: options.maxConnections, + readTimeoutMillis: options.readTimeoutMillis, + maxResponseHeaderBytes: options.maxResponseHeaderBytes, + keepAliveTimeMillis: options.keepAliveTimeMillis, + proxy: options.proxy, + }); const interceptors = options.interceptors; if (typeof interceptors === 'function') { @@ -103,6 +158,23 @@ export default class Connection extends EventEmitter { if (options.auth) { this._interceptors.push(options.auth); } + + this._log('debug', `Connection created for ${this.url}`); + } + + /** + * The connection-level default batch size, used to fill a request's `batchSize` when unset. + */ + get batchSize(): number { + return this._batchSize; + } + + /** + * The connection-level default for `bulkResults`, applied to every request unless overridden + * per-request. Defaults to `false`. + */ + get bulkResults(): boolean { + return this._bulkResults; } /** @@ -178,7 +250,7 @@ export default class Connection extends EventEmitter { } if (!response.body) { - // 204 No Content — nothing to yield + // 204 No Content - nothing to yield return; } @@ -190,7 +262,7 @@ export default class Connection extends EventEmitter { completed = true; } finally { if (!completed) { - // Consumer broke out early or an error occurred — abort to release the connection + // Consumer broke out early or an error occurred - abort to release the connection abortController.abort(); } } @@ -213,6 +285,10 @@ export default class Connection extends EventEmitter { 'Accept': this._reader.mimeType, }; + if (this._compression === 'deflate') { + headers['Accept-Encoding'] = 'deflate'; + } + if (this._enableUserAgentOnConnect) { const userAgent = await utils.getUserAgent(); if (userAgent !== undefined) { @@ -220,12 +296,6 @@ export default class Connection extends EventEmitter { } } - if (this.options.headers) { - Object.entries(this.options.headers).forEach(([key, value]) => { - headers[key] = Array.isArray(value) ? value.join(', ') : value; - }); - } - const httpRequest = new HttpRequest('POST', this.url, headers, request); // Promote transactionId to HTTP header before interceptors run. @@ -249,12 +319,17 @@ export default class Connection extends EventEmitter { // Auto-serialize body to JSON after interceptors run (idempotent if already serialized) httpRequest.serializeBody(); - return fetch(httpRequest.url, { + this._log('debug', `Sending ${httpRequest.method} request to ${httpRequest.url}`); + + return httpFetch.fetch(httpRequest.url, { method: httpRequest.method, headers: httpRequest.headers, body: httpRequest.body, signal, - }); + // Node only: the undici dispatcher carries the connection-pool options. In the browser + // it is undefined and the field is omitted, letting the user agent manage the transport. + ...(this._dispatcher ? { dispatcher: this._dispatcher } : {}), + } as RequestInit); } async #handleResponse(response: Response) { @@ -321,7 +396,8 @@ export default class Connection extends EventEmitter { * Closes the Connection. * @return {Promise} */ - close() { - return Promise.resolve(); + async close() { + this._log('debug', `Closing connection to ${this.url}`); + await this._dispatcher?.close(); } } diff --git a/gremlin-js/gremlin-javascript/lib/driver/dispatcher.browser.ts b/gremlin-js/gremlin-javascript/lib/driver/dispatcher.browser.ts new file mode 100644 index 00000000000..8f80cbb014f --- /dev/null +++ b/gremlin-js/gremlin-javascript/lib/driver/dispatcher.browser.ts @@ -0,0 +1,79 @@ +/* + * 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. + */ + +/** + * Browser counterpart of {@link ./dispatcher.ts}. The Node build configures the underlying HTTP + * transport through an `undici` dispatcher, but `undici` is built on `node:net`/`node:tls` and + * cannot run in a browser. Bundlers pick this file up via the `"browser"` field in `package.json`, + * so a browser bundle never imports `undici`. + * + * The browser's HTTP stack manages the connection-pool options that the Node dispatcher carries, + * so {@link buildDispatcher} throws if any is set explicitly (rather than silently ignoring it) + * and otherwise returns `undefined`, leaving the connection to issue a plain `fetch`. It is the + * only export the connection imports through the swapped `./dispatcher.js` path. + */ + +/** Transport options accepted by {@link buildDispatcher}; mirrors the Node dispatcher's options. */ +type DispatcherOptions = { + maxConnections?: number; + readTimeoutMillis?: number; + maxResponseHeaderBytes?: number; + keepAliveTimeMillis?: number; + proxy?: string; +}; + +/** + * Connection-pool / transport options that only the Node (`undici`) dispatcher can honor. The + * browser's HTTP stack owns these concerns and exposes no `fetch` API to configure them, so they + * are rejected here rather than silently ignored. `readTimeoutMillis` is intentionally not in this list: + * it has a browser analog (an `AbortSignal`-based timeout) and is handled by the connection. + */ +const NODE_ONLY_OPTIONS: (keyof DispatcherOptions)[] = [ + 'maxConnections', + 'keepAliveTimeMillis', + 'maxResponseHeaderBytes', + 'proxy', +]; + +/** + * In the browser there is no `undici` dispatcher, so `fetch` is issued without one and the browser + * manages the transport. Any {@link NODE_ONLY_OPTIONS} set explicitly cannot be honored and throws, + * so a misconfiguration surfaces immediately instead of being silently dropped. With none set this + * returns `undefined` and the connection omits the `dispatcher` field. + */ +export function buildDispatcher(options: DispatcherOptions = {}): undefined { + const unsupported = NODE_ONLY_OPTIONS.filter((key) => options[key] !== undefined); + if (unsupported.length > 0) { + throw new Error( + `The following connection options are not supported in the browser and are managed by the ` + + `browser's HTTP stack: ${unsupported.join(', ')}. Remove them when running in a browser ` + + `(they are supported in Node).`, + ); + } + return undefined; +} + +/** + * Browser counterpart of the Node `httpFetch` holder. There is no `undici` in the browser, so + * requests use the global `fetch` and the user agent manages the transport. Same holder shape as + * the Node build. + */ +export const httpFetch: { fetch: typeof globalThis.fetch } = { + fetch: globalThis.fetch.bind(globalThis), +}; diff --git a/gremlin-js/gremlin-javascript/lib/driver/dispatcher.ts b/gremlin-js/gremlin-javascript/lib/driver/dispatcher.ts new file mode 100644 index 00000000000..7e323aff77a --- /dev/null +++ b/gremlin-js/gremlin-javascript/lib/driver/dispatcher.ts @@ -0,0 +1,124 @@ +/* + * 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. + */ + +import { Agent, ProxyAgent, buildConnector, Dispatcher, fetch as undiciFetch } from 'undici'; + +/** Default concurrent-connections-per-origin cap (Node's global fetch is uncapped). */ +export const DEFAULT_MAX_CONNECTIONS = 128; + +/** Canonical TinkerPop 4.x default TCP keep-alive idle time (30s), shared across the GLVs. */ +export const DEFAULT_KEEP_ALIVE_TIME = 30000; + +/** Options for the default undici {@link Dispatcher}; unset fields use undici defaults. */ +export type DispatcherOptions = { + /** Max concurrent connections per origin. Defaults to {@link DEFAULT_MAX_CONNECTIONS}. */ + maxConnections?: number; + /** Idle-read (body) timeout in ms. Maps to undici `bodyTimeout`. */ + readTimeoutMillis?: number; + /** Max response header size in bytes. Maps to undici `maxHeaderSize`. */ + maxResponseHeaderBytes?: number; + /** Idle ms before TCP keep-alive probes begin. Defaults to 30s; `0` disables keep-alive. */ + keepAliveTimeMillis?: number; + /** HTTP proxy URI. When set, a {@link ProxyAgent} is built instead of an {@link Agent}. */ + proxy?: string; +}; + +/** + * Resolves the effective keep-alive idle delay: unset falls back to {@link DEFAULT_KEEP_ALIVE_TIME}, + * a non-positive value disables keep-alive (returns `null`). + */ +export function resolveKeepAliveTime(keepAliveTime?: number): number | null { + const effective = keepAliveTime ?? DEFAULT_KEEP_ALIVE_TIME; + return effective > 0 ? effective : null; +} + +/** + * Builds an undici connector that sets `SO_KEEPALIVE` with the given idle delay on each new socket. + * `baseConnector` is parameterised so the wiring can be unit-tested with a fake connector. + */ +export function buildKeepAliveConnector( + delay: number, + baseConnector: ReturnType = buildConnector({}), +): ReturnType { + return (connectOptions: buildConnector.Options, callback: buildConnector.Callback) => { + baseConnector(connectOptions, (err, socket) => { + if (err) { + callback(err, null); + return; + } + if (socket) { + socket.setKeepAlive(true, delay); + } + callback(null, socket); + }); + }; +} + +/** + * Maps the discrete driver options onto undici {@link Agent.Options} (the single source of truth + * for that mapping). Exported as a seam so the mapping can be asserted in unit tests. + */ +export function buildAgentOptions(options: DispatcherOptions = {}): Agent.Options { + const maxConnections = options.maxConnections ?? DEFAULT_MAX_CONNECTIONS; + + const agentOptions: Agent.Options = { + connections: maxConnections, + }; + + // Connect/idle timeouts are intentionally left to undici defaults (the GLV spec marks the JS + // connect/idle timeout as N/A), not exposed as driver options. + if (options.readTimeoutMillis !== undefined) { + agentOptions.bodyTimeout = options.readTimeoutMillis; + } + if (options.maxResponseHeaderBytes !== undefined) { + agentOptions.maxHeaderSize = options.maxResponseHeaderBytes; + } + + const keepAliveDelay = resolveKeepAliveTime(options.keepAliveTimeMillis); + if (keepAliveDelay !== null) { + agentOptions.connect = buildKeepAliveConnector(keepAliveDelay); + } + + return agentOptions; +} + +/** + * Builds the default undici {@link Dispatcher}: a {@link ProxyAgent} when `proxy` is set, otherwise + * a plain {@link Agent}. (Agent and ProxyAgent share one dispatcher slot, so only one is built.) + */ +export function buildDispatcher(options: DispatcherOptions = {}): Dispatcher { + const agentOptions = buildAgentOptions(options); + + if (options.proxy) { + return new ProxyAgent({ uri: options.proxy, ...agentOptions }); + } + + return new Agent(agentOptions); +} + +/** + * HTTP `fetch` for the Node build. Uses undici's own `fetch` (not the global one) so `fetch` and the + * {@link buildDispatcher} dispatcher share one undici version, whatever undici the Node runtime + * bundles (e.g. Node 26 ships undici 8, incompatible with an undici 6 dispatcher). A holder object, + * not a bare export, so tests can swap `fetch` (an ESM export can't be reassigned, an object + * property can). The cast aligns undici's `fetch` type with the global one. + */ +export const httpFetch: { fetch: typeof globalThis.fetch } = { + fetch: undiciFetch as unknown as typeof globalThis.fetch, +}; diff --git a/gremlin-js/gremlin-javascript/lib/driver/logger.ts b/gremlin-js/gremlin-javascript/lib/driver/logger.ts new file mode 100644 index 00000000000..7de8558ea41 --- /dev/null +++ b/gremlin-js/gremlin-javascript/lib/driver/logger.ts @@ -0,0 +1,71 @@ +/* + * 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. + */ + +/** The set of log levels emitted by the driver, ordered from least to most severe. */ +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +/** + * A logger object with one method per {@link LogLevel}. This shape is compatible with the + * global `console` as well as common logging libraries. + */ +export interface LoggerObject { + debug(message: string, ...args: any[]): void; + info(message: string, ...args: any[]): void; + warn(message: string, ...args: any[]): void; + error(message: string, ...args: any[]): void; +} + +/** + * A logger callback that receives the level alongside the message and any extra arguments. + */ +export type LoggerCallback = (level: LogLevel, message: string, ...args: any[]) => void; + +/** + * The `logger` connection option. It may be either a {@link LoggerObject} (e.g. the global + * `console`) or a {@link LoggerCallback}. Logging is disabled when no logger is provided. + */ +export type Logger = LoggerObject | LoggerCallback; + +/** + * Normalizes the user-supplied {@link Logger} option into a single callback. When no logger is + * provided, a no-op callback is returned so call sites do not need to null-check. + * + * @param {Logger} [logger] The user-supplied logger option. + * @returns {LoggerCallback} A callback that dispatches to the configured logger. + */ +export function normalizeLogger(logger?: Logger): LoggerCallback { + if (logger === undefined || logger === null) { + return () => {}; + } + + if (typeof logger === 'function') { + return logger; + } + + if (typeof logger === 'object') { + return (level: LogLevel, message: string, ...args: any[]) => { + const fn = (logger as LoggerObject)[level]; + if (typeof fn === 'function') { + fn.call(logger, message, ...args); + } + }; + } + + throw new TypeError('logger must be a function, a logger object, or undefined'); +} diff --git a/gremlin-js/gremlin-javascript/lib/driver/request-message.ts b/gremlin-js/gremlin-javascript/lib/driver/request-message.ts index c8783099354..29dc55219c6 100644 --- a/gremlin-js/gremlin-javascript/lib/driver/request-message.ts +++ b/gremlin-js/gremlin-javascript/lib/driver/request-message.ts @@ -27,6 +27,7 @@ const Tokens = { ARGS_MATERIALIZE_PROPERTIES: 'materializeProperties', TIMEOUT_MS: 'timeoutMs', BULK_RESULTS: 'bulkResults', + BATCH_SIZE: 'batchSize', MATERIALIZE_PROPERTIES_TOKENS: 'tokens', MATERIALIZE_PROPERTIES_ALL: 'all' }; @@ -42,6 +43,7 @@ export class RequestMessage { private g?: string; private materializeProperties?: string; private bulkResults?: boolean; + private batchSize?: number; private customFields: Map; private constructor( @@ -52,6 +54,7 @@ export class RequestMessage { g: string | undefined, materializeProperties: string | undefined, bulkResults: boolean | undefined, + batchSize: number | undefined, customFields: Map ) { if (!gremlin) { @@ -65,6 +68,7 @@ export class RequestMessage { this.g = g; this.materializeProperties = materializeProperties; this.bulkResults = bulkResults; + this.batchSize = batchSize; this.customFields = customFields; } @@ -96,6 +100,10 @@ export class RequestMessage { return this.bulkResults; } + getBatchSize(): number | undefined { + return this.batchSize; + } + getFields(): ReadonlyMap { return this.customFields; } @@ -129,6 +137,9 @@ export class RequestMessage { if (this.bulkResults !== undefined) { payload['bulkResults'] = this.bulkResults; } + if (this.batchSize !== undefined) { + payload['batchSize'] = this.batchSize; + } // Flatten custom/provider fields to the top level this.customFields.forEach((v, k) => { @@ -155,6 +166,7 @@ export class Builder { public g?: string; public materializeProperties?: string; public bulkResults?: boolean; + public batchSize?: number; public additionalFields = new Map(); constructor(gremlin: string) { @@ -230,6 +242,12 @@ export class Builder { return this; } + addBatchSize(batchSize: number): Builder { + if (batchSize <= 0) throw new Error('batchSize argument must be positive.'); + this.batchSize = batchSize; + return this; + } + addField(key: string, value: any): Builder { this.additionalFields.set(key, value); return this; @@ -256,6 +274,7 @@ export class Builder { this.g, this.materializeProperties, this.bulkResults, + this.batchSize, this.additionalFields ); } diff --git a/gremlin-js/gremlin-javascript/package.json b/gremlin-js/gremlin-javascript/package.json index 5ec1746c926..44191d8e9cd 100644 --- a/gremlin-js/gremlin-javascript/package.json +++ b/gremlin-js/gremlin-javascript/package.json @@ -20,6 +20,10 @@ "type": "module", "main": "./build/cjs/index.cjs", "types": "./build/esm/index.d.ts", + "browser": { + "./build/esm/driver/dispatcher.js": "./build/esm/driver/dispatcher.browser.js", + "./build/cjs/driver/dispatcher.cjs": "./build/cjs/driver/dispatcher.browser.cjs" + }, "exports": { ".": { "import": "./build/esm/index.js", @@ -44,7 +48,8 @@ "dependencies": { "antlr4ng": "3.0.16", "buffer": "^6.0.3", - "eventemitter3": "^5.0.1" + "eventemitter3": "^5.0.1", + "undici": "6.27.0" }, "optionalDependencies": { "@aws-sdk/credential-providers": "^3.967.0", diff --git a/gremlin-js/gremlin-javascript/test/helper.js b/gremlin-js/gremlin-javascript/test/helper.js index fa7f0d65b29..fab04525782 100644 --- a/gremlin-js/gremlin-javascript/test/helper.js +++ b/gremlin-js/gremlin-javascript/test/helper.js @@ -52,7 +52,6 @@ export function getClient(traversalSource) { export function getAuthenticatedClient(traversalSource, interceptors) { return new Client(serverAuthUrl, { traversalSource, - rejectUnauthorized: false, interceptors, }); } @@ -60,7 +59,6 @@ export function getAuthenticatedClient(traversalSource, interceptors) { export function getAuthenticatedConnection(traversalSource, interceptors) { return new DriverRemoteConnection(serverAuthUrl, { traversalSource, - rejectUnauthorized: false, interceptors, }); } diff --git a/gremlin-js/gremlin-javascript/test/unit/client-test.js b/gremlin-js/gremlin-javascript/test/unit/client-test.js index 38342a3a9f6..df39f1bceb2 100644 --- a/gremlin-js/gremlin-javascript/test/unit/client-test.js +++ b/gremlin-js/gremlin-javascript/test/unit/client-test.js @@ -51,4 +51,100 @@ describe('Client', function () { customClient._connection = connectionMock; customClient.submit(query) }); + + it('should fill batchSize from the connection default when unset', function () { + const connectionMock = { + batchSize: 64, + submit: function (requestMessage) { + assert.strictEqual(requestMessage.getBatchSize(), 64); + return Promise.resolve(); + } + }; + + const customClient = new Client('http://localhost:9321', {connectOnStartup: false}); + customClient._connection = connectionMock; + customClient.submit(query); + }); + + it('should let a per-request batchSize override the connection default', function () { + const connectionMock = { + batchSize: 64, + submit: function (requestMessage) { + assert.strictEqual(requestMessage.getBatchSize(), 10); + return Promise.resolve(); + } + }; + + const customClient = new Client('http://localhost:9321', {connectOnStartup: false}); + customClient._connection = connectionMock; + customClient.submit(query, null, { batchSize: 10 }); + }); + + it('should forward an explicit bulkResults:false', function () { + const connectionMock = { + submit: function (requestMessage) { + assert.strictEqual(requestMessage.getBulkResults(), false); + return Promise.resolve(); + } + }; + + const customClient = new Client('http://localhost:9321', {connectOnStartup: false}); + customClient._connection = connectionMock; + customClient.submit(query, null, { bulkResults: false }); + }); + + it('should forward an explicit bulkResults:true', function () { + const connectionMock = { + submit: function (requestMessage) { + assert.strictEqual(requestMessage.getBulkResults(), true); + return Promise.resolve(); + } + }; + + const customClient = new Client('http://localhost:9321', {connectOnStartup: false}); + customClient._connection = connectionMock; + customClient.submit(query, null, { bulkResults: true }); + }); + + it('should fill bulkResults from the connection-level option when unset per-request', function () { + const connectionMock = { + bulkResults: true, + submit: function (requestMessage) { + assert.strictEqual(requestMessage.getBulkResults(), true); + return Promise.resolve(); + } + }; + + const customClient = new Client('http://localhost:9321', {connectOnStartup: false}); + customClient._connection = connectionMock; + customClient.submit(query); + }); + + it('should let a per-request bulkResults:false override the connection-level true', function () { + const connectionMock = { + bulkResults: true, + submit: function (requestMessage) { + assert.strictEqual(requestMessage.getBulkResults(), false); + return Promise.resolve(); + } + }; + + const customClient = new Client('http://localhost:9321', {connectOnStartup: false}); + customClient._connection = connectionMock; + customClient.submit(query, null, { bulkResults: false }); + }); + + it('should not set bulkResults when neither per-request nor connection-level is set', function () { + const connectionMock = { + bulkResults: undefined, + submit: function (requestMessage) { + assert.strictEqual(requestMessage.getBulkResults(), undefined); + return Promise.resolve(); + } + }; + + const customClient = new Client('http://localhost:9321', {connectOnStartup: false}); + customClient._connection = connectionMock; + customClient.submit(query); + }); }); diff --git a/gremlin-js/gremlin-javascript/test/unit/connection-test.js b/gremlin-js/gremlin-javascript/test/unit/connection-test.js index 2ef6cc477ca..ffc2ea739ea 100644 --- a/gremlin-js/gremlin-javascript/test/unit/connection-test.js +++ b/gremlin-js/gremlin-javascript/test/unit/connection-test.js @@ -20,19 +20,20 @@ import assert from 'assert'; import Connection from '../../lib/driver/connection.js'; import { RequestMessage } from '../../lib/driver/request-message.js'; +import { httpFetch } from '../../lib/driver/dispatcher.js'; // Connection-level unit tests that verify interceptor wiring and auto-serialization -// by mocking the global fetch and capturing what the Connection sends. +// by mocking the driver's fetch (httpFetch.fetch) and capturing what the Connection sends. describe('Connection (request pipeline)', function () { let originalFetch; let captured; beforeEach(function () { captured = null; - originalFetch = global.fetch; + originalFetch = httpFetch.fetch; // Return an error response so we don't need a valid GraphBinary body; the test only // cares about what was sent to fetch. The resulting ResponseError is swallowed per-test. - global.fetch = (url, init) => { + httpFetch.fetch = (url, init) => { captured = { url, init }; return Promise.resolve({ ok: false, @@ -45,7 +46,7 @@ describe('Connection (request pipeline)', function () { }); afterEach(function () { - global.fetch = originalFetch; + httpFetch.fetch = originalFetch; }); function makeConnection(options = {}) { @@ -147,4 +148,62 @@ describe('Connection (request pipeline)', function () { assert.deepStrictEqual(order, [1, 2, 3], 'auth interceptor should always run last'); }); + + it('sends Accept-Encoding: deflate by default (compression on)', async function () { + const conn = makeConnection(); + await submitAndIgnoreError(conn, RequestMessage.build('g.V()').addG('g').create()); + + assert.strictEqual(captured.init.headers['Accept-Encoding'], 'deflate'); + }); + + it('does not send an Accept-Encoding header when compression is explicitly off', async function () { + const conn = makeConnection({ compression: 'none' }); + await submitAndIgnoreError(conn, RequestMessage.build('g.V()').addG('g').create()); + + assert.strictEqual(captured.init.headers['Accept-Encoding'], undefined); + }); + + it('throws for an invalid compression value', function () { + assert.throws( + () => makeConnection({ compression: 'gzip' }), + /compression must be/); + }); + + it('passes the default dispatcher to fetch', async function () { + const conn = makeConnection(); + await submitAndIgnoreError(conn, RequestMessage.build('g.V()').addG('g').create()); + + assert.ok(captured.init.dispatcher, 'fetch should receive a dispatcher'); + }); + + it('closes its built dispatcher on close()', async function () { + const conn = makeConnection(); + await submitAndIgnoreError(conn, RequestMessage.build('g.V()').addG('g').create()); + + const built = captured.init.dispatcher; + assert.ok(built, 'a dispatcher should have been built and passed to fetch'); + + await conn.close(); + // A closed undici Agent reports itself as destroyed; closing it again is a no-op. + assert.strictEqual(built.closed === true || built.destroyed === true, true, + 'the connection-built dispatcher should be closed on close()'); + }); + + it('exposes the default batch size of 64', function () { + const conn = makeConnection(); + assert.strictEqual(conn.batchSize, 64); + }); + + it('honors a custom batchSize', function () { + const conn = makeConnection({ batchSize: 250 }); + assert.strictEqual(conn.batchSize, 250); + }); + + it('invokes a logger callback during the request lifecycle', async function () { + const lines = []; + const conn = makeConnection({ logger: (level, message) => lines.push([level, message]) }); + await submitAndIgnoreError(conn, RequestMessage.build('g.V()').addG('g').create()); + + assert.ok(lines.some(([, m]) => /Sending POST request/.test(m)), 'should log the outgoing request'); + }); }); diff --git a/gremlin-js/gremlin-javascript/test/unit/dispatcher-browser-test.js b/gremlin-js/gremlin-javascript/test/unit/dispatcher-browser-test.js new file mode 100644 index 00000000000..5cf15c958ab --- /dev/null +++ b/gremlin-js/gremlin-javascript/test/unit/dispatcher-browser-test.js @@ -0,0 +1,59 @@ +/* + * 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. + */ + +import assert from 'assert'; +import { buildDispatcher } from '../../lib/driver/dispatcher.browser.js'; + +describe('dispatcher (browser)', function () { + it('returns undefined when no transport options are set', function () { + assert.strictEqual(buildDispatcher(), undefined); + assert.strictEqual(buildDispatcher({}), undefined); + }); + + it('returns undefined when only readTimeoutMillis is set (handled by the connection, not the dispatcher)', function () { + assert.strictEqual(buildDispatcher({ readTimeoutMillis: 5000 }), undefined); + }); + + for (const [key, value] of [ + ['maxConnections', 10], + ['keepAliveTimeMillis', 15000], + ['maxResponseHeaderBytes', 8192], + ['proxy', 'http://proxy.local:8080'], + ]) { + it(`throws when ${key} is set explicitly`, function () { + assert.throws(() => buildDispatcher({ [key]: value }), (err) => { + assert.ok(err instanceof Error); + assert.ok(err.message.includes(key), `message should name ${key}`); + return true; + }); + }); + } + + it('lists every unsupported option that was set', function () { + assert.throws( + () => buildDispatcher({ maxConnections: 10, proxy: 'http://p:1', readTimeoutMillis: 1000 }), + (err) => { + assert.ok(err.message.includes('maxConnections')); + assert.ok(err.message.includes('proxy')); + assert.ok(!err.message.includes('readTimeoutMillis'), 'readTimeoutMillis is supported and must not be listed'); + return true; + }, + ); + }); +}); diff --git a/gremlin-js/gremlin-javascript/test/unit/dispatcher-test.js b/gremlin-js/gremlin-javascript/test/unit/dispatcher-test.js new file mode 100644 index 00000000000..8cfc4add0b6 --- /dev/null +++ b/gremlin-js/gremlin-javascript/test/unit/dispatcher-test.js @@ -0,0 +1,187 @@ +/* + * 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. + */ + +import assert from 'assert'; +import { Agent, ProxyAgent } from 'undici'; +import { + buildDispatcher, + buildAgentOptions, + buildKeepAliveConnector, + resolveKeepAliveTime, + DEFAULT_MAX_CONNECTIONS, + DEFAULT_KEEP_ALIVE_TIME, +} from '../../lib/driver/dispatcher.js'; + +describe('dispatcher', function () { + it('builds a plain Agent by default', async function () { + const d = buildDispatcher(); + assert.ok(d instanceof Agent, 'should be an undici Agent'); + assert.ok(!(d instanceof ProxyAgent), 'should not be a ProxyAgent without a proxy'); + await d.close(); + }); + + it('defaults the connection cap to 128', function () { + assert.strictEqual(DEFAULT_MAX_CONNECTIONS, 128); + }); + + it('defaults the keep-alive idle time to 30000ms', function () { + assert.strictEqual(DEFAULT_KEEP_ALIVE_TIME, 30000); + }); + + it('builds a plain Agent when only timeouts and header size are set', async function () { + const d = buildDispatcher({ readTimeoutMillis: 1000, maxResponseHeaderBytes: 16384, maxConnections: 8 }); + assert.ok(d instanceof Agent); + assert.ok(!(d instanceof ProxyAgent)); + await d.close(); + }); + + it('builds a ProxyAgent when a proxy is configured', async function () { + const d = buildDispatcher({ proxy: 'http://localhost:3128' }); + assert.ok(d instanceof ProxyAgent, 'should be an undici ProxyAgent'); + await d.close(); + }); + + it('builds a single dispatcher with keep-alive wired through a custom connector', async function () { + const d = buildDispatcher({ keepAliveTimeMillis: 30000 }); + assert.ok(d instanceof Agent); + await d.close(); + }); + + it('builds a ProxyAgent with keep-alive and timeouts together', async function () { + const d = buildDispatcher({ + proxy: 'http://localhost:3128', + keepAliveTimeMillis: 5000, + readTimeoutMillis: 2000, + maxResponseHeaderBytes: 8192, + maxConnections: 64, + }); + assert.ok(d instanceof ProxyAgent); + await d.close(); + }); + + describe('buildAgentOptions (undici option mapping)', function () { + it('maps readTimeoutMillis to the undici Agent bodyTimeout', function () { + const opts = buildAgentOptions({ readTimeoutMillis: 1234 }); + assert.strictEqual(opts.bodyTimeout, 1234); + }); + + it('maps maxResponseHeaderBytes to the undici Agent maxHeaderSize', function () { + const opts = buildAgentOptions({ maxResponseHeaderBytes: 16384 }); + assert.strictEqual(opts.maxHeaderSize, 16384); + }); + + it('maps maxConnections to the undici Agent connections', function () { + const opts = buildAgentOptions({ maxConnections: 8 }); + assert.strictEqual(opts.connections, 8); + }); + + it('defaults connections to DEFAULT_MAX_CONNECTIONS when unset', function () { + const opts = buildAgentOptions(); + assert.strictEqual(opts.connections, DEFAULT_MAX_CONNECTIONS); + }); + + it('omits bodyTimeout and maxHeaderSize when their options are unset', function () { + const opts = buildAgentOptions(); + assert.strictEqual(opts.bodyTimeout, undefined); + assert.strictEqual(opts.maxHeaderSize, undefined); + }); + + it('maps both readTimeoutMillis and maxResponseHeaderBytes together', function () { + const opts = buildAgentOptions({ readTimeoutMillis: 2000, maxResponseHeaderBytes: 8192 }); + assert.strictEqual(opts.bodyTimeout, 2000); + assert.strictEqual(opts.maxHeaderSize, 8192); + }); + + it('wires a custom connect connector for keep-alive by default', function () { + const opts = buildAgentOptions(); + assert.strictEqual(typeof opts.connect, 'function', 'keep-alive should install a connect connector'); + }); + + it('omits the connect connector when keep-alive is disabled (keepAliveTimeMillis 0)', function () { + const opts = buildAgentOptions({ keepAliveTimeMillis: 0 }); + assert.strictEqual(opts.connect, undefined); + }); + }); + + describe('resolveKeepAliveTime', function () { + it('falls back to the 30000ms default when unset', function () { + assert.strictEqual(resolveKeepAliveTime(undefined), DEFAULT_KEEP_ALIVE_TIME); + assert.strictEqual(resolveKeepAliveTime(), 30000); + }); + + it('honors a user-supplied positive override', function () { + assert.strictEqual(resolveKeepAliveTime(5000), 5000); + }); + + it('disables keep-alive (null) when set to 0 or a negative value', function () { + assert.strictEqual(resolveKeepAliveTime(0), null); + assert.strictEqual(resolveKeepAliveTime(-1), null); + }); + }); + + describe('buildKeepAliveConnector', function () { + // Drives the connector with a fake base connector and socket so the applied + // keep-alive delay can be asserted without opening a real socket. + function fakeBaseConnector(socket, err) { + return (_connectOptions, callback) => callback(err ?? null, err ? null : socket); + } + + function fakeSocket() { + const calls = []; + return { + calls, + setKeepAlive(enable, delay) { + calls.push({ enable, delay }); + }, + }; + } + + it('applies the default 30000ms delay to a freshly established socket', function (done) { + const socket = fakeSocket(); + const delay = resolveKeepAliveTime(undefined); + const connector = buildKeepAliveConnector(delay, fakeBaseConnector(socket)); + connector({}, (err, returned) => { + assert.ifError(err); + assert.strictEqual(returned, socket); + assert.deepStrictEqual(socket.calls, [{ enable: true, delay: 30000 }]); + done(); + }); + }); + + it('applies a user-supplied override delay', function (done) { + const socket = fakeSocket(); + const connector = buildKeepAliveConnector(resolveKeepAliveTime(7500), fakeBaseConnector(socket)); + connector({}, (err) => { + assert.ifError(err); + assert.deepStrictEqual(socket.calls, [{ enable: true, delay: 7500 }]); + done(); + }); + }); + + it('propagates connector errors without touching the socket', function (done) { + const boom = new Error('connect failed'); + const connector = buildKeepAliveConnector(30000, fakeBaseConnector(null, boom)); + connector({}, (err, returned) => { + assert.strictEqual(err, boom); + assert.strictEqual(returned, null); + done(); + }); + }); + }); +}); diff --git a/gremlin-js/gremlin-javascript/test/unit/logger-test.js b/gremlin-js/gremlin-javascript/test/unit/logger-test.js new file mode 100644 index 00000000000..53d3d3a664b --- /dev/null +++ b/gremlin-js/gremlin-javascript/test/unit/logger-test.js @@ -0,0 +1,67 @@ +/* + * 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. + */ + +import assert from 'assert'; +import { normalizeLogger } from '../../lib/driver/logger.js'; + +describe('logger', function () { + it('returns a no-op when no logger is provided', function () { + const log = normalizeLogger(); + // Should not throw and should not require a target. + assert.doesNotThrow(() => log('info', 'hello')); + }); + + it('returns a no-op for null', function () { + const log = normalizeLogger(null); + assert.doesNotThrow(() => log('warn', 'hello')); + }); + + it('passes through a callback logger with level, message, and args', function () { + const calls = []; + const log = normalizeLogger((level, message, ...args) => calls.push([level, message, args])); + log('debug', 'msg', 1, 2); + assert.deepStrictEqual(calls, [['debug', 'msg', [1, 2]]]); + }); + + it('dispatches to a logger object method matching the level', function () { + const calls = []; + const obj = { + debug: (m, ...a) => calls.push(['debug', m, a]), + info: (m, ...a) => calls.push(['info', m, a]), + warn: (m, ...a) => calls.push(['warn', m, a]), + error: (m, ...a) => calls.push(['error', m, a]), + }; + const log = normalizeLogger(obj); + log('info', 'hi', 'x'); + log('error', 'boom'); + assert.deepStrictEqual(calls, [ + ['info', 'hi', ['x']], + ['error', 'boom', []], + ]); + }); + + it('silently ignores levels missing on the logger object', function () { + const log = normalizeLogger({ error: () => { throw new Error('should not be called'); } }); + assert.doesNotThrow(() => log('debug', 'hello')); + }); + + it('throws for an unsupported logger type', function () { + assert.throws(() => normalizeLogger(42), /logger must be/); + }); +}); diff --git a/gremlin-js/gremlin-mcp/src/gremlin/connection.ts b/gremlin-js/gremlin-mcp/src/gremlin/connection.ts index 7e45d3101b0..8742878da7d 100644 --- a/gremlin-js/gremlin-mcp/src/gremlin/connection.ts +++ b/gremlin-js/gremlin-mcp/src/gremlin/connection.ts @@ -72,7 +72,7 @@ const createConnection = ( authenticatorInput: Option.Option<{ username: string; password: string }> ): Effect.Effect => Effect.gen(function* () { - const protocol = useSSL ? 'wss' : 'ws'; + const protocol = useSSL ? 'https' : 'http'; const url = `${protocol}://${host}:${port}/gremlin`; yield* Effect.logInfo('Creating Gremlin connection', { @@ -82,21 +82,16 @@ const createConnection = ( traversalSource, }); - const headers = Option.match(authenticatorInput, { + const auth = Option.match(authenticatorInput, { onNone: () => undefined, - onSome: ({ username, password }) => { - const credentials = `${username}:${password}`; - return { - Authorization: `Basic ${Buffer.from(credentials).toString('base64')}`, - }; - }, + onSome: ({ username, password }) => driver.auth.basic(username, password), }); const connection = yield* Effect.try({ try: () => new DriverRemoteConnection(url, { traversalSource, - headers, + auth, }), catch: error => Errors.connection('Failed to create remote connection', { error }), }); @@ -104,7 +99,7 @@ const createConnection = ( const g = AnonymousTraversalSource.traversal().withRemote(connection); const client = new Client(url, { traversalSource, - headers, + auth, }); // Verify the server is reachable before caching diff --git a/gremlin-js/package-lock.json b/gremlin-js/package-lock.json index 8c03d05eb13..b6957bc304a 100644 --- a/gremlin-js/package-lock.json +++ b/gremlin-js/package-lock.json @@ -18,7 +18,8 @@ "dependencies": { "antlr4ng": "3.0.16", "buffer": "^6.0.3", - "eventemitter3": "^5.0.1" + "eventemitter3": "^5.0.1", + "undici": "6.27.0" }, "devDependencies": { "@cucumber/cucumber": "~12.7.0", @@ -71,6 +72,15 @@ } } }, + "gremlin-javascript/node_modules/undici": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "gremlin-mcp": { "version": "4.0.0-alpha1", "hasInstallScript": true,