From 7dfe22acf612e6dba2a0ccc2387a767ce076f661 Mon Sep 17 00:00:00 2001 From: Guian Gumpac Date: Fri, 19 Jun 2026 13:57:13 -0700 Subject: [PATCH 1/7] Standardized `gremlin-js` connection options Assisted-by: Kiro: Claude Opus 4.8 --- CHANGELOG.asciidoc | 14 ++ docs/src/reference/gremlin-variants.asciidoc | 21 +- docs/src/upgrade/release-4.x.x.asciidoc | 43 ++++ .../gremlin-javascript/connections.js | 11 +- .../gremlin-javascript/lib/driver/client.ts | 11 +- .../lib/driver/connection.ts | 132 +++++++++++-- .../lib/driver/dispatcher.ts | 113 +++++++++++ .../gremlin-javascript/lib/driver/logger.ts | 71 +++++++ .../lib/driver/request-message.ts | 19 ++ gremlin-js/gremlin-javascript/package.json | 3 +- gremlin-js/gremlin-javascript/test/helper.js | 2 - .../test/unit/client-test.js | 96 +++++++++ .../test/unit/connection-test.js | 97 +++++++++ .../test/unit/dispatcher-test.js | 187 ++++++++++++++++++ .../test/unit/logger-test.js | 67 +++++++ gremlin-js/package-lock.json | 12 +- 16 files changed, 866 insertions(+), 33 deletions(-) create mode 100644 gremlin-js/gremlin-javascript/lib/driver/dispatcher.ts create mode 100644 gremlin-js/gremlin-javascript/lib/driver/logger.ts create mode 100644 gremlin-js/gremlin-javascript/test/unit/dispatcher-test.js create mode 100644 gremlin-js/gremlin-javascript/test/unit/logger-test.js diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc index c77c4062b9b..e18632b062c 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -25,6 +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-javascript` connection options per the TinkerPop 4.x GLV proposal: *(breaking)* +** Adopted `undici` as a pinned dependency that ships a default dispatcher built from the discrete connection options below. +** Added `maxConnections` (default 128), which caps concurrent connections per origin where it was previously uncapped. *(breaking)* +** Added `readTimeout`, a per-read idle (body) timeout in milliseconds applied to the default dispatcher. +** Added `maxResponseHeaderBytes`, the maximum size of the response headers in bytes applied to the default dispatcher. +** Added `keepAliveTime` (default 30s/30000ms), the idle time before TCP keep-alive probes begin, applied to the default dispatcher via a `SO_KEEPALIVE` connector; set to `0` to disable. +** Added `proxy`, which routes requests through an undici `ProxyAgent`. +** Added `compression`, a `'none'`/`'deflate'` string union defaulting to `'deflate'` (on). *(breaking)* +** Added `defaultBatchSize` (default 64), a connection-level default that fills a request's `batchSize` when it is left unset. +** Added a connection-level `bulkResults` default, applied to every request unless overridden per-request. +** Added `logger`, a logger object or callback (logging is disabled when unset). +** Removed the dead `agent`, `ca`, `cert`, `pfx`, and `rejectUnauthorized` TLS fields; TLS is now configured via the Node/undici runtime. *(breaking)* +** Deprecated the standalone `headers` option (now implemented via an interceptor); use interceptors directly. +* Fixed `gremlin-javascript` `Client.submit()` so that an explicit `bulkResults: false` request option is forwarded to the server instead of being silently dropped. * 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)* diff --git a/docs/src/reference/gremlin-variants.asciidoc b/docs/src/reference/gremlin-variants.asciidoc index 941e2e83ddc..8e4f0164d2e 100644 --- a/docs/src/reference/gremlin-variants.asciidoc +++ b/docs/src/reference/gremlin-variants.asciidoc @@ -1984,18 +1984,29 @@ 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.readTimeout |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.keepAliveTime |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.defaultBatchSize |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.headers |Object |Deprecated: set custom headers via an interceptor instead. Additional HTTP header key/values included with each request. |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'; }`. + [[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..b308ec32de8 100644 --- a/docs/src/upgrade/release-4.x.x.asciidoc +++ b/docs/src/upgrade/release-4.x.x.asciidoc @@ -32,6 +32,49 @@ complete list of all the modifications that are part of this release. === Upgrading for Users +==== Standardizing JavaScript Connection Options + +TinkerPop 4.x standardizes connection option names and defaults across the GLVs. In `gremlin-javascript`, the driver +now adopts `undici` as a pinned dependency that ships a default dispatcher, several dead options have been removed, the +standalone `headers` option has been deprecated, and a number of new options have been added. The notes below describe +the JavaScript changes. See <> for the equivalent changes in the other +drivers. + +Renames (deprecated aliases). The following option has been deprecated. It still works but should be migrated: + +- `headers` is deprecated. The standalone `headers` option is now implemented via a synthesized interceptor, so custom +headers continue to work without re-introducing dead configuration. Prefer setting custom headers via an interceptor +directly, e.g. `interceptors: (req) => { req.headers['X-Custom'] = 'value'; }`. When the `headers` option is set, it is +applied by an interceptor that runs before the auth interceptor, so SigV4 still signs over those headers. + +Behavior changes. These change runtime behavior on upgrade, even if you do not change your configuration: + +- `compression` now defaults to `'deflate'` (on), so the driver sends `Accept-Encoding: deflate` by default. It is a +`'none'`/`'deflate'` string union. Set `'none'` to disable it. +- `maxConnections` now caps concurrent connections per origin at 128 by default, where the number of connections was +previously uncapped. + +New options: + +- `readTimeout`: a per-read idle (body) timeout in milliseconds applied to the default dispatcher (undici +`bodyTimeout`). It resets per chunk, so it is safe for streaming. +- `maxResponseHeaderBytes`: the maximum size of the response headers in bytes applied to the default dispatcher (undici +`maxHeaderSize`). +- `keepAliveTime` (default 30000 ms): the idle time before TCP keep-alive probes begin, applied to the default +dispatcher via a `SO_KEEPALIVE` connector. Set 0 to disable. +- `proxy`: an HTTP proxy URI that routes requests through an undici `ProxyAgent`. +- `defaultBatchSize` (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. +- `logger`: a logger object (with `debug`/`info`/`warn`/`error` methods) or a `(level, message, ...args)` callback. +Logging is disabled when unset. + +The dead `agent`, `ca`, `cert`, `pfx`, and `rejectUnauthorized` fields have been removed. They were declared but never +wired. TLS is now configured through the Node/undici runtime (for example `NODE_EXTRA_CA_CERTS` or +`NODE_TLS_REJECT_UNAUTHORIZED`). *(breaking)* + +See: link:https://lists.apache.org/thread/yqtr2wnb1kq2pqqq4002cz511q5o0bkg[dev list discussion on standardizing GLV connection options]. + ==== Standardizing .NET Connection Options TinkerPop 4.x standardizes connection option names and defaults across the GLVs. In `gremlin-dotnet`, several 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..162fd0263d4 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.defaultBatchSize; + 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..cd96f7ef41a 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 } from './dispatcher.js'; +import { Logger, LoggerCallback, normalizeLogger } from './logger.js'; const responseStatusCode = { success: 200, @@ -40,24 +42,58 @@ 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. */ + readTimeout?: 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. + */ + keepAliveTime?: 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. */ + defaultBatchSize?: 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[]; + /** + * Custom headers to attach to every outgoing request. + * + * @deprecated As of release 4.0.0, use an interceptor to set custom headers, e.g. + * `interceptors: (req) => { req.headers['X-Custom'] = 'value'; }`. When set, these headers + * are applied via a synthesized interceptor that runs before any auth interceptor, so they + * compose with explicit interceptors and remain visible to request signing. + */ + headers?: Record; /** 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 +105,11 @@ export default class Connection extends EventEmitter { private readonly _enableUserAgentOnConnect: boolean; private readonly _interceptors: RequestInterceptor[]; + private readonly _dispatcher: Dispatcher; + private readonly _compression: Compression; + private readonly _defaultBatchSize: number; + private readonly _bulkResults: boolean; + private readonly _log: LoggerCallback; /** * Creates a new instance of {@link Connection}. @@ -87,6 +128,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._defaultBatchSize = options.defaultBatchSize ?? 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, + readTimeout: options.readTimeout, + maxResponseHeaderBytes: options.maxResponseHeaderBytes, + keepAliveTime: options.keepAliveTime, + proxy: options.proxy, + }); const interceptors = options.interceptors; if (typeof interceptors === 'function') { @@ -99,10 +163,39 @@ export default class Connection extends EventEmitter { throw new TypeError('interceptors must be a function, array, or undefined'); } + // The deprecated `headers` option is implemented as a synthesized interceptor so custom + // headers still work without re-introducing dead configuration. It is appended after user + // interceptors (and before auth, which is always last) so it behaves like a user interceptor: + // explicit interceptors run first, then these headers are merged, then auth signs over them. + if (options.headers) { + const headers = { ...options.headers }; + this._log('warn', "The 'headers' connection option is deprecated; set custom headers via an interceptor instead."); + this._interceptors.push((request) => { + Object.assign(request.headers, headers); + }); + } + // Auth interceptor is always last so it runs after user interceptors 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 defaultBatchSize(): number { + return this._defaultBatchSize; + } + + /** + * 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 +271,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 +283,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 +306,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 +317,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 +340,16 @@ export default class Connection extends EventEmitter { // Auto-serialize body to JSON after interceptors run (idempotent if already serialized) httpRequest.serializeBody(); + this._log('debug', `Sending ${httpRequest.method} request to ${httpRequest.url}`); + return fetch(httpRequest.url, { method: httpRequest.method, headers: httpRequest.headers, body: httpRequest.body, signal, - }); + // undici transparently decodes the deflate response based on the Content-Encoding header. + dispatcher: this._dispatcher, + } as RequestInit); } async #handleResponse(response: Response) { @@ -321,7 +416,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.ts b/gremlin-js/gremlin-javascript/lib/driver/dispatcher.ts new file mode 100644 index 00000000000..90f17234aa6 --- /dev/null +++ b/gremlin-js/gremlin-javascript/lib/driver/dispatcher.ts @@ -0,0 +1,113 @@ +/* + * 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 } 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`. */ + readTimeout?: 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. */ + keepAliveTime?: 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.readTimeout !== undefined) { + agentOptions.bodyTimeout = options.readTimeout; + } + if (options.maxResponseHeaderBytes !== undefined) { + agentOptions.maxHeaderSize = options.maxResponseHeaderBytes; + } + + const keepAliveDelay = resolveKeepAliveTime(options.keepAliveTime); + 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); +} 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..92803e5f2d2 100644 --- a/gremlin-js/gremlin-javascript/package.json +++ b/gremlin-js/gremlin-javascript/package.json @@ -44,7 +44,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..08253f1b32d 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 = { + defaultBatchSize: 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 = { + defaultBatchSize: 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..bac0e8c5859 100644 --- a/gremlin-js/gremlin-javascript/test/unit/connection-test.js +++ b/gremlin-js/gremlin-javascript/test/unit/connection-test.js @@ -147,4 +147,101 @@ 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.defaultBatchSize, 64); + }); + + it('honors a custom defaultBatchSize', function () { + const conn = makeConnection({ defaultBatchSize: 250 }); + assert.strictEqual(conn.defaultBatchSize, 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'); + }); + + it('applies the deprecated headers option to the outgoing request via a synthesized interceptor', async function () { + const conn = makeConnection({ headers: { 'X-Custom': 'value', 'X-Another': 'two' } }); + await submitAndIgnoreError(conn, RequestMessage.build('g.V()').addG('g').create()); + + assert.strictEqual(captured.init.headers['X-Custom'], 'value'); + assert.strictEqual(captured.init.headers['X-Another'], 'two'); + }); + + it('emits a one-time deprecation warning for the headers option', function () { + const lines = []; + makeConnection({ headers: { 'X-Custom': 'value' }, logger: (level, message) => lines.push([level, message]) }); + + const warnings = lines.filter(([level, m]) => level === 'warn' && /headers.*deprecated/i.test(m)); + assert.strictEqual(warnings.length, 1, 'should warn exactly once about the deprecated headers option'); + }); + + it('composes the deprecated headers option with explicit interceptors and auth', async function () { + const order = []; + const conn = makeConnection({ + headers: { 'X-Headers-Option': 'h' }, + interceptors: [ + (req) => { order.push('interceptor'); req.headers['X-Interceptor'] = 'i'; }, + ], + auth: (req) => { + order.push('auth'); + // Auth runs last, so the headers-option header is already present and visible to signing. + req.headers['X-Auth-Saw-Headers-Option'] = String(req.headers['X-Headers-Option'] === 'h'); + }, + }); + await submitAndIgnoreError(conn, RequestMessage.build('g.V()').addG('g').create()); + + // Explicit interceptor runs first, then the synthesized headers interceptor, then auth last. + assert.deepStrictEqual(order, ['interceptor', 'auth']); + assert.strictEqual(captured.init.headers['X-Interceptor'], 'i'); + assert.strictEqual(captured.init.headers['X-Headers-Option'], 'h'); + assert.strictEqual(captured.init.headers['X-Auth-Saw-Headers-Option'], 'true', + 'auth should run after the headers option is applied'); + }); }); 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..bfdb78d1699 --- /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({ readTimeout: 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({ keepAliveTime: 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', + keepAliveTime: 5000, + readTimeout: 2000, + maxResponseHeaderBytes: 8192, + maxConnections: 64, + }); + assert.ok(d instanceof ProxyAgent); + await d.close(); + }); + + describe('buildAgentOptions (undici option mapping)', function () { + it('maps readTimeout to the undici Agent bodyTimeout', function () { + const opts = buildAgentOptions({ readTimeout: 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 readTimeout and maxResponseHeaderBytes together', function () { + const opts = buildAgentOptions({ readTimeout: 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 (keepAliveTime 0)', function () { + const opts = buildAgentOptions({ keepAliveTime: 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/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, From 3f446e901d4920a641c4c1cef434fb76a5f0956a Mon Sep 17 00:00:00 2001 From: Guian Gumpac Date: Thu, 25 Jun 2026 08:49:57 -0700 Subject: [PATCH 2/7] Browser support --- .../lib/driver/connection.ts | 9 +-- .../lib/driver/dispatcher.browser.ts | 70 +++++++++++++++++++ gremlin-js/gremlin-javascript/package.json | 4 ++ .../test/unit/dispatcher-browser-test.js | 59 ++++++++++++++++ 4 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 gremlin-js/gremlin-javascript/lib/driver/dispatcher.browser.ts create mode 100644 gremlin-js/gremlin-javascript/test/unit/dispatcher-browser-test.js diff --git a/gremlin-js/gremlin-javascript/lib/driver/connection.ts b/gremlin-js/gremlin-javascript/lib/driver/connection.ts index cd96f7ef41a..ca054151511 100644 --- a/gremlin-js/gremlin-javascript/lib/driver/connection.ts +++ b/gremlin-js/gremlin-javascript/lib/driver/connection.ts @@ -105,7 +105,7 @@ export default class Connection extends EventEmitter { private readonly _enableUserAgentOnConnect: boolean; private readonly _interceptors: RequestInterceptor[]; - private readonly _dispatcher: Dispatcher; + private readonly _dispatcher: Dispatcher | undefined; private readonly _compression: Compression; private readonly _defaultBatchSize: number; private readonly _bulkResults: boolean; @@ -347,8 +347,9 @@ export default class Connection extends EventEmitter { headers: httpRequest.headers, body: httpRequest.body, signal, - // undici transparently decodes the deflate response based on the Content-Encoding header. - dispatcher: this._dispatcher, + // 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); } @@ -418,6 +419,6 @@ export default class Connection extends EventEmitter { */ async close() { this._log('debug', `Closing connection to ${this.url}`); - await this._dispatcher.close(); + 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..36da111e2f1 --- /dev/null +++ b/gremlin-js/gremlin-javascript/lib/driver/dispatcher.browser.ts @@ -0,0 +1,70 @@ +/* + * 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; + readTimeout?: number; + maxResponseHeaderBytes?: number; + keepAliveTime?: 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. `readTimeout` 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', + 'keepAliveTime', + '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; +} diff --git a/gremlin-js/gremlin-javascript/package.json b/gremlin-js/gremlin-javascript/package.json index 92803e5f2d2..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", 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..9c5fe58fb7f --- /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 readTimeout is set (handled by the connection, not the dispatcher)', function () { + assert.strictEqual(buildDispatcher({ readTimeout: 5000 }), undefined); + }); + + for (const [key, value] of [ + ['maxConnections', 10], + ['keepAliveTime', 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', readTimeout: 1000 }), + (err) => { + assert.ok(err.message.includes('maxConnections')); + assert.ok(err.message.includes('proxy')); + assert.ok(!err.message.includes('readTimeout'), 'readTimeout is supported and must not be listed'); + return true; + }, + ); + }); +}); From b8e0ae05523592de5346066704c8fb769bc1d539 Mon Sep 17 00:00:00 2001 From: Guian Gumpac Date: Thu, 25 Jun 2026 08:58:43 -0700 Subject: [PATCH 3/7] GLV parity based on comments in other PRs Removed deprecated Updated doc link defaultBatchSize to batchSize timeout to timeoutMillis Assisted-by: Kiro: Claude Opus 4.8 --- CHANGELOG.asciidoc | 8 ++-- docs/src/reference/gremlin-variants.asciidoc | 21 +++++++-- docs/src/upgrade/release-4.x.x.asciidoc | 21 ++++----- .../gremlin-javascript/lib/driver/client.ts | 2 +- .../lib/driver/connection.ts | 39 ++++----------- .../lib/driver/dispatcher.browser.ts | 8 ++-- .../lib/driver/dispatcher.ts | 10 ++-- .../test/unit/client-test.js | 4 +- .../test/unit/connection-test.js | 47 ++----------------- .../test/unit/dispatcher-browser-test.js | 10 ++-- .../test/unit/dispatcher-test.js | 20 ++++---- 11 files changed, 71 insertions(+), 119 deletions(-) diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc index e18632b062c..2e572e63e57 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -28,16 +28,16 @@ image::https://raw.githubusercontent.com/apache/tinkerpop/master/docs/static/ima * Standardized `gremlin-javascript` connection options per the TinkerPop 4.x GLV proposal: *(breaking)* ** Adopted `undici` as a pinned dependency that ships a default dispatcher built from the discrete connection options below. ** Added `maxConnections` (default 128), which caps concurrent connections per origin where it was previously uncapped. *(breaking)* -** Added `readTimeout`, a per-read idle (body) timeout in milliseconds applied to the default dispatcher. +** Added `readTimeoutMillis`, a per-read idle (body) timeout in milliseconds applied to the default dispatcher. ** Added `maxResponseHeaderBytes`, the maximum size of the response headers in bytes applied to the default dispatcher. -** Added `keepAliveTime` (default 30s/30000ms), the idle time before TCP keep-alive probes begin, applied to the default dispatcher via a `SO_KEEPALIVE` connector; set to `0` to disable. +** Added `keepAliveTimeMillis` (default 30000), the idle time in milliseconds before TCP keep-alive probes begin, applied to the default dispatcher via a `SO_KEEPALIVE` connector; set to `0` to disable. ** Added `proxy`, which routes requests through an undici `ProxyAgent`. ** Added `compression`, a `'none'`/`'deflate'` string union defaulting to `'deflate'` (on). *(breaking)* -** Added `defaultBatchSize` (default 64), a connection-level default that fills a request's `batchSize` when it is left unset. +** Added `batchSize` (default 64), a connection-level default that fills a request's `batchSize` when it is left unset. ** Added a connection-level `bulkResults` default, applied to every request unless overridden per-request. ** Added `logger`, a logger object or callback (logging is disabled when unset). ** Removed the dead `agent`, `ca`, `cert`, `pfx`, and `rejectUnauthorized` TLS fields; TLS is now configured via the Node/undici runtime. *(breaking)* -** Deprecated the standalone `headers` option (now implemented via an interceptor); use interceptors directly. +** Removed the standalone `headers` option; set custom headers via an interceptor instead. * Fixed `gremlin-javascript` `Client.submit()` so that an explicit `bulkResults: false` request option is forwarded to the server instead of being silently dropped. * 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)* diff --git a/docs/src/reference/gremlin-variants.asciidoc b/docs/src/reference/gremlin-variants.asciidoc index 8e4f0164d2e..33e9f990ff8 100644 --- a/docs/src/reference/gremlin-variants.asciidoc +++ b/docs/src/reference/gremlin-variants.asciidoc @@ -1986,17 +1986,16 @@ can be passed in the constructor of a new `Client` or `DriverRemoteConnection` : |options |Object |The connection options. |{} |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.readTimeout |Number |A per-read idle timeout in milliseconds (undici `bodyTimeout`). It resets per chunk, so it is safe for streaming. |runtime default +|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.keepAliveTime |Number |Idle time in milliseconds before TCP keep-alive probes begin. Enables `SO_KEEPALIVE` on the socket. Set to `0` to disable. |30000 +|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.defaultBatchSize |Number |Connection-level default that fills a request's `batchSize` when it is left unset. |64 +|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.headers |Object |Deprecated: set custom headers via an interceptor instead. Additional HTTP header key/values included with each request. |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.enableUserAgentOnConnect |Boolean |Determines if a user agent header will be sent with requests. |true @@ -2007,6 +2006,20 @@ TLS is configured through the Node.js/undici runtime (for example the `NODE_EXTR `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 b308ec32de8..e895c17e284 100644 --- a/docs/src/upgrade/release-4.x.x.asciidoc +++ b/docs/src/upgrade/release-4.x.x.asciidoc @@ -36,16 +36,15 @@ complete list of all the modifications that are part of this release. TinkerPop 4.x standardizes connection option names and defaults across the GLVs. In `gremlin-javascript`, the driver now adopts `undici` as a pinned dependency that ships a default dispatcher, several dead options have been removed, the -standalone `headers` option has been deprecated, and a number of new options have been added. The notes below describe +standalone `headers` option has been removed, and a number of new options have been added. The notes below describe the JavaScript changes. See <> for the equivalent changes in the other drivers. -Renames (deprecated aliases). The following option has been deprecated. It still works but should be migrated: +Removed options. The following option has been removed: -- `headers` is deprecated. The standalone `headers` option is now implemented via a synthesized interceptor, so custom -headers continue to work without re-introducing dead configuration. Prefer setting custom headers via an interceptor -directly, e.g. `interceptors: (req) => { req.headers['X-Custom'] = 'value'; }`. When the `headers` option is set, it is -applied by an interceptor that runs before the auth interceptor, so SigV4 still signs over those headers. +- `headers` has been removed. Set custom headers via an interceptor instead, e.g. +`interceptors: (req) => { req.headers['X-Custom'] = 'value'; }`. Because auth runs as the last interceptor, SigV4 still +signs over headers added this way. Behavior changes. These change runtime behavior on upgrade, even if you do not change your configuration: @@ -56,14 +55,14 @@ previously uncapped. New options: -- `readTimeout`: a per-read idle (body) timeout in milliseconds applied to the default dispatcher (undici +- `readTimeoutMillis`: a per-read idle (body) timeout in milliseconds applied to the default dispatcher (undici `bodyTimeout`). It resets per chunk, so it is safe for streaming. - `maxResponseHeaderBytes`: the maximum size of the response headers in bytes applied to the default dispatcher (undici `maxHeaderSize`). -- `keepAliveTime` (default 30000 ms): the idle time before TCP keep-alive probes begin, applied to the default -dispatcher via a `SO_KEEPALIVE` connector. Set 0 to disable. +- `keepAliveTimeMillis` (default 30000): the idle time in milliseconds before TCP keep-alive probes begin, applied to +the default dispatcher via a `SO_KEEPALIVE` connector. Set 0 to disable. - `proxy`: an HTTP proxy URI that routes requests through an undici `ProxyAgent`. -- `defaultBatchSize` (default 64): a connection-level default that fills a request's `batchSize` when it is left unset. +- `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. - `logger`: a logger object (with `debug`/`info`/`warn`/`error` methods) or a `(level, message, ...args)` callback. @@ -73,7 +72,7 @@ The dead `agent`, `ca`, `cert`, `pfx`, and `rejectUnauthorized` fields have been wired. TLS is now configured through the Node/undici runtime (for example `NODE_EXTRA_CA_CERTS` or `NODE_TLS_REJECT_UNAUTHORIZED`). *(breaking)* -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 .NET Connection Options diff --git a/gremlin-js/gremlin-javascript/lib/driver/client.ts b/gremlin-js/gremlin-javascript/lib/driver/client.ts index 162fd0263d4..b13493b208e 100644 --- a/gremlin-js/gremlin-javascript/lib/driver/client.ts +++ b/gremlin-js/gremlin-javascript/lib/driver/client.ts @@ -134,7 +134,7 @@ export default class Client { requestBuilder.addBulkResults(true); } // Fill the per-request batchSize from the connection-level default when unset. - const batchSize = requestOptions?.batchSize ?? this._connection.defaultBatchSize; + const batchSize = requestOptions?.batchSize ?? this._connection.batchSize; if (batchSize !== undefined) { requestBuilder.addBatchSize(batchSize); } diff --git a/gremlin-js/gremlin-javascript/lib/driver/connection.ts b/gremlin-js/gremlin-javascript/lib/driver/connection.ts index ca054151511..f8edaf4b44d 100644 --- a/gremlin-js/gremlin-javascript/lib/driver/connection.ts +++ b/gremlin-js/gremlin-javascript/lib/driver/connection.ts @@ -58,34 +58,25 @@ export type ConnectionOptions = { /** Maximum number of concurrent connections per origin. Defaults to 128. */ maxConnections?: number; /** Idle-read (body) timeout in milliseconds, applied to the default dispatcher. */ - readTimeout?: number; + 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. */ - keepAliveTime?: number; + 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. */ - defaultBatchSize?: number; + 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[]; - /** - * Custom headers to attach to every outgoing request. - * - * @deprecated As of release 4.0.0, use an interceptor to set custom headers, e.g. - * `interceptors: (req) => { req.headers['X-Custom'] = 'value'; }`. When set, these headers - * are applied via a synthesized interceptor that runs before any auth interceptor, so they - * compose with explicit interceptors and remain visible to request signing. - */ - headers?: Record; /** 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; @@ -107,7 +98,7 @@ export default class Connection extends EventEmitter { private readonly _interceptors: RequestInterceptor[]; private readonly _dispatcher: Dispatcher | undefined; private readonly _compression: Compression; - private readonly _defaultBatchSize: number; + private readonly _batchSize: number; private readonly _bulkResults: boolean; private readonly _log: LoggerCallback; @@ -138,7 +129,7 @@ export default class Connection extends EventEmitter { throw new TypeError(`compression must be 'none' or 'deflate'`); } - this._defaultBatchSize = options.defaultBatchSize ?? DEFAULT_BATCH_SIZE; + this._batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE; this._bulkResults = options.bulkResults ?? false; // The driver builds and owns the undici dispatcher from the discrete options. @@ -146,9 +137,9 @@ export default class Connection extends EventEmitter { // not through a driver option. this._dispatcher = buildDispatcher({ maxConnections: options.maxConnections, - readTimeout: options.readTimeout, + readTimeoutMillis: options.readTimeoutMillis, maxResponseHeaderBytes: options.maxResponseHeaderBytes, - keepAliveTime: options.keepAliveTime, + keepAliveTimeMillis: options.keepAliveTimeMillis, proxy: options.proxy, }); @@ -163,18 +154,6 @@ export default class Connection extends EventEmitter { throw new TypeError('interceptors must be a function, array, or undefined'); } - // The deprecated `headers` option is implemented as a synthesized interceptor so custom - // headers still work without re-introducing dead configuration. It is appended after user - // interceptors (and before auth, which is always last) so it behaves like a user interceptor: - // explicit interceptors run first, then these headers are merged, then auth signs over them. - if (options.headers) { - const headers = { ...options.headers }; - this._log('warn', "The 'headers' connection option is deprecated; set custom headers via an interceptor instead."); - this._interceptors.push((request) => { - Object.assign(request.headers, headers); - }); - } - // Auth interceptor is always last so it runs after user interceptors if (options.auth) { this._interceptors.push(options.auth); @@ -186,8 +165,8 @@ export default class Connection extends EventEmitter { /** * The connection-level default batch size, used to fill a request's `batchSize` when unset. */ - get defaultBatchSize(): number { - return this._defaultBatchSize; + get batchSize(): number { + return this._batchSize; } /** diff --git a/gremlin-js/gremlin-javascript/lib/driver/dispatcher.browser.ts b/gremlin-js/gremlin-javascript/lib/driver/dispatcher.browser.ts index 36da111e2f1..b09d34d1ef6 100644 --- a/gremlin-js/gremlin-javascript/lib/driver/dispatcher.browser.ts +++ b/gremlin-js/gremlin-javascript/lib/driver/dispatcher.browser.ts @@ -32,21 +32,21 @@ /** Transport options accepted by {@link buildDispatcher}; mirrors the Node dispatcher's options. */ type DispatcherOptions = { maxConnections?: number; - readTimeout?: number; + readTimeoutMillis?: number; maxResponseHeaderBytes?: number; - keepAliveTime?: 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. `readTimeout` is intentionally not in this list: + * 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', - 'keepAliveTime', + 'keepAliveTimeMillis', 'maxResponseHeaderBytes', 'proxy', ]; diff --git a/gremlin-js/gremlin-javascript/lib/driver/dispatcher.ts b/gremlin-js/gremlin-javascript/lib/driver/dispatcher.ts index 90f17234aa6..e1e5e2183e9 100644 --- a/gremlin-js/gremlin-javascript/lib/driver/dispatcher.ts +++ b/gremlin-js/gremlin-javascript/lib/driver/dispatcher.ts @@ -30,11 +30,11 @@ 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`. */ - readTimeout?: number; + 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. */ - keepAliveTime?: number; + keepAliveTimeMillis?: number; /** HTTP proxy URI. When set, a {@link ProxyAgent} is built instead of an {@link Agent}. */ proxy?: string; }; @@ -83,14 +83,14 @@ export function buildAgentOptions(options: DispatcherOptions = {}): Agent.Option // 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.readTimeout !== undefined) { - agentOptions.bodyTimeout = options.readTimeout; + if (options.readTimeoutMillis !== undefined) { + agentOptions.bodyTimeout = options.readTimeoutMillis; } if (options.maxResponseHeaderBytes !== undefined) { agentOptions.maxHeaderSize = options.maxResponseHeaderBytes; } - const keepAliveDelay = resolveKeepAliveTime(options.keepAliveTime); + const keepAliveDelay = resolveKeepAliveTime(options.keepAliveTimeMillis); if (keepAliveDelay !== null) { agentOptions.connect = buildKeepAliveConnector(keepAliveDelay); } diff --git a/gremlin-js/gremlin-javascript/test/unit/client-test.js b/gremlin-js/gremlin-javascript/test/unit/client-test.js index 08253f1b32d..df39f1bceb2 100644 --- a/gremlin-js/gremlin-javascript/test/unit/client-test.js +++ b/gremlin-js/gremlin-javascript/test/unit/client-test.js @@ -54,7 +54,7 @@ describe('Client', function () { it('should fill batchSize from the connection default when unset', function () { const connectionMock = { - defaultBatchSize: 64, + batchSize: 64, submit: function (requestMessage) { assert.strictEqual(requestMessage.getBatchSize(), 64); return Promise.resolve(); @@ -68,7 +68,7 @@ describe('Client', function () { it('should let a per-request batchSize override the connection default', function () { const connectionMock = { - defaultBatchSize: 64, + batchSize: 64, submit: function (requestMessage) { assert.strictEqual(requestMessage.getBatchSize(), 10); return Promise.resolve(); diff --git a/gremlin-js/gremlin-javascript/test/unit/connection-test.js b/gremlin-js/gremlin-javascript/test/unit/connection-test.js index bac0e8c5859..e674cd823a7 100644 --- a/gremlin-js/gremlin-javascript/test/unit/connection-test.js +++ b/gremlin-js/gremlin-javascript/test/unit/connection-test.js @@ -190,12 +190,12 @@ describe('Connection (request pipeline)', function () { it('exposes the default batch size of 64', function () { const conn = makeConnection(); - assert.strictEqual(conn.defaultBatchSize, 64); + assert.strictEqual(conn.batchSize, 64); }); - it('honors a custom defaultBatchSize', function () { - const conn = makeConnection({ defaultBatchSize: 250 }); - assert.strictEqual(conn.defaultBatchSize, 250); + 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 () { @@ -205,43 +205,4 @@ describe('Connection (request pipeline)', function () { assert.ok(lines.some(([, m]) => /Sending POST request/.test(m)), 'should log the outgoing request'); }); - - it('applies the deprecated headers option to the outgoing request via a synthesized interceptor', async function () { - const conn = makeConnection({ headers: { 'X-Custom': 'value', 'X-Another': 'two' } }); - await submitAndIgnoreError(conn, RequestMessage.build('g.V()').addG('g').create()); - - assert.strictEqual(captured.init.headers['X-Custom'], 'value'); - assert.strictEqual(captured.init.headers['X-Another'], 'two'); - }); - - it('emits a one-time deprecation warning for the headers option', function () { - const lines = []; - makeConnection({ headers: { 'X-Custom': 'value' }, logger: (level, message) => lines.push([level, message]) }); - - const warnings = lines.filter(([level, m]) => level === 'warn' && /headers.*deprecated/i.test(m)); - assert.strictEqual(warnings.length, 1, 'should warn exactly once about the deprecated headers option'); - }); - - it('composes the deprecated headers option with explicit interceptors and auth', async function () { - const order = []; - const conn = makeConnection({ - headers: { 'X-Headers-Option': 'h' }, - interceptors: [ - (req) => { order.push('interceptor'); req.headers['X-Interceptor'] = 'i'; }, - ], - auth: (req) => { - order.push('auth'); - // Auth runs last, so the headers-option header is already present and visible to signing. - req.headers['X-Auth-Saw-Headers-Option'] = String(req.headers['X-Headers-Option'] === 'h'); - }, - }); - await submitAndIgnoreError(conn, RequestMessage.build('g.V()').addG('g').create()); - - // Explicit interceptor runs first, then the synthesized headers interceptor, then auth last. - assert.deepStrictEqual(order, ['interceptor', 'auth']); - assert.strictEqual(captured.init.headers['X-Interceptor'], 'i'); - assert.strictEqual(captured.init.headers['X-Headers-Option'], 'h'); - assert.strictEqual(captured.init.headers['X-Auth-Saw-Headers-Option'], 'true', - 'auth should run after the headers option is applied'); - }); }); diff --git a/gremlin-js/gremlin-javascript/test/unit/dispatcher-browser-test.js b/gremlin-js/gremlin-javascript/test/unit/dispatcher-browser-test.js index 9c5fe58fb7f..5cf15c958ab 100644 --- a/gremlin-js/gremlin-javascript/test/unit/dispatcher-browser-test.js +++ b/gremlin-js/gremlin-javascript/test/unit/dispatcher-browser-test.js @@ -26,13 +26,13 @@ describe('dispatcher (browser)', function () { assert.strictEqual(buildDispatcher({}), undefined); }); - it('returns undefined when only readTimeout is set (handled by the connection, not the dispatcher)', function () { - assert.strictEqual(buildDispatcher({ readTimeout: 5000 }), 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], - ['keepAliveTime', 15000], + ['keepAliveTimeMillis', 15000], ['maxResponseHeaderBytes', 8192], ['proxy', 'http://proxy.local:8080'], ]) { @@ -47,11 +47,11 @@ describe('dispatcher (browser)', function () { it('lists every unsupported option that was set', function () { assert.throws( - () => buildDispatcher({ maxConnections: 10, proxy: 'http://p:1', readTimeout: 1000 }), + () => 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('readTimeout'), 'readTimeout is supported and must not be listed'); + 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 index bfdb78d1699..8cfc4add0b6 100644 --- a/gremlin-js/gremlin-javascript/test/unit/dispatcher-test.js +++ b/gremlin-js/gremlin-javascript/test/unit/dispatcher-test.js @@ -45,7 +45,7 @@ describe('dispatcher', function () { }); it('builds a plain Agent when only timeouts and header size are set', async function () { - const d = buildDispatcher({ readTimeout: 1000, maxResponseHeaderBytes: 16384, maxConnections: 8 }); + const d = buildDispatcher({ readTimeoutMillis: 1000, maxResponseHeaderBytes: 16384, maxConnections: 8 }); assert.ok(d instanceof Agent); assert.ok(!(d instanceof ProxyAgent)); await d.close(); @@ -58,7 +58,7 @@ describe('dispatcher', function () { }); it('builds a single dispatcher with keep-alive wired through a custom connector', async function () { - const d = buildDispatcher({ keepAliveTime: 30000 }); + const d = buildDispatcher({ keepAliveTimeMillis: 30000 }); assert.ok(d instanceof Agent); await d.close(); }); @@ -66,8 +66,8 @@ describe('dispatcher', function () { it('builds a ProxyAgent with keep-alive and timeouts together', async function () { const d = buildDispatcher({ proxy: 'http://localhost:3128', - keepAliveTime: 5000, - readTimeout: 2000, + keepAliveTimeMillis: 5000, + readTimeoutMillis: 2000, maxResponseHeaderBytes: 8192, maxConnections: 64, }); @@ -76,8 +76,8 @@ describe('dispatcher', function () { }); describe('buildAgentOptions (undici option mapping)', function () { - it('maps readTimeout to the undici Agent bodyTimeout', function () { - const opts = buildAgentOptions({ readTimeout: 1234 }); + it('maps readTimeoutMillis to the undici Agent bodyTimeout', function () { + const opts = buildAgentOptions({ readTimeoutMillis: 1234 }); assert.strictEqual(opts.bodyTimeout, 1234); }); @@ -102,8 +102,8 @@ describe('dispatcher', function () { assert.strictEqual(opts.maxHeaderSize, undefined); }); - it('maps both readTimeout and maxResponseHeaderBytes together', function () { - const opts = buildAgentOptions({ readTimeout: 2000, maxResponseHeaderBytes: 8192 }); + 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); }); @@ -113,8 +113,8 @@ describe('dispatcher', function () { assert.strictEqual(typeof opts.connect, 'function', 'keep-alive should install a connect connector'); }); - it('omits the connect connector when keep-alive is disabled (keepAliveTime 0)', function () { - const opts = buildAgentOptions({ keepAliveTime: 0 }); + it('omits the connect connector when keep-alive is disabled (keepAliveTimeMillis 0)', function () { + const opts = buildAgentOptions({ keepAliveTimeMillis: 0 }); assert.strictEqual(opts.connect, undefined); }); }); From 280cf5e26ca5d02bc909a234bb92219dfd760155 Mon Sep 17 00:00:00 2001 From: Guian Gumpac Date: Thu, 25 Jun 2026 10:19:52 -0700 Subject: [PATCH 4/7] Addressed comments --- CHANGELOG.asciidoc | 2 +- docs/src/upgrade/release-4.x.x.asciidoc | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc index 2e572e63e57..975a1ce38c8 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -36,7 +36,7 @@ image::https://raw.githubusercontent.com/apache/tinkerpop/master/docs/static/ima ** Added `batchSize` (default 64), a connection-level default that fills a request's `batchSize` when it is left unset. ** Added a connection-level `bulkResults` default, applied to every request unless overridden per-request. ** Added `logger`, a logger object or callback (logging is disabled when unset). -** Removed the dead `agent`, `ca`, `cert`, `pfx`, and `rejectUnauthorized` TLS fields; TLS is now configured via the Node/undici runtime. *(breaking)* +** Removed the `ca`, `cert`, `pfx`, `rejectUnauthorized`, and `agent` options; TLS is now configured via the Node/undici runtime and the HTTP agent is owned by the driver's dispatcher. *(breaking)* ** Removed the standalone `headers` option; set custom headers via an interceptor instead. * Fixed `gremlin-javascript` `Client.submit()` so that an explicit `bulkResults: false` request option is forwarded to the server instead of being silently dropped. * Standardized `gremlin-dotnet` connection options per the TinkerPop 4.x GLV proposal: diff --git a/docs/src/upgrade/release-4.x.x.asciidoc b/docs/src/upgrade/release-4.x.x.asciidoc index e895c17e284..318a66b09d5 100644 --- a/docs/src/upgrade/release-4.x.x.asciidoc +++ b/docs/src/upgrade/release-4.x.x.asciidoc @@ -35,8 +35,8 @@ complete list of all the modifications that are part of this release. ==== Standardizing JavaScript Connection Options TinkerPop 4.x standardizes connection option names and defaults across the GLVs. In `gremlin-javascript`, the driver -now adopts `undici` as a pinned dependency that ships a default dispatcher, several dead options have been removed, the -standalone `headers` option has been removed, and a number of new options have been added. The notes below describe +now adopts `undici` as a pinned dependency that ships a default dispatcher, several options have been removed +(including the standalone `headers` option), and a number of new options have been added. The notes below describe the JavaScript changes. See <> for the equivalent changes in the other drivers. @@ -68,9 +68,9 @@ overridden per-request. The `DriverRemoteConnection` traversal path defaults to - `logger`: a logger object (with `debug`/`info`/`warn`/`error` methods) or a `(level, message, ...args)` callback. Logging is disabled when unset. -The dead `agent`, `ca`, `cert`, `pfx`, and `rejectUnauthorized` fields have been removed. They were declared but never -wired. TLS is now configured through the Node/undici runtime (for example `NODE_EXTRA_CA_CERTS` or -`NODE_TLS_REJECT_UNAUTHORIZED`). *(breaking)* +The `ca`, `cert`, `pfx`, `rejectUnauthorized`, and `agent` options have been removed. TLS is now configured through the +Node/undici runtime (for example `NODE_EXTRA_CA_CERTS` or `NODE_TLS_REJECT_UNAUTHORIZED`) and the HTTP agent is owned +by the driver's dispatcher. *(breaking)* See: link:https://lists.apache.org/thread/yqtr2wnb1kq2pqqq4002cz511q5o0bkg[[DISCUSS] Standardizing GLV connection options in TinkerPop 4]. From 2a44719f36b536a8ce5ee015d8d9711aa9282bef Mon Sep 17 00:00:00 2001 From: Guian Gumpac Date: Mon, 29 Jun 2026 14:35:36 -0700 Subject: [PATCH 5/7] Consolidated options docs --- CHANGELOG.asciidoc | 70 +----- docs/src/upgrade/release-4.x.x.asciidoc | 312 ++++++------------------ 2 files changed, 79 insertions(+), 303 deletions(-) diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc index 975a1ce38c8..1d42e1d8640 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -25,74 +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-javascript` connection options per the TinkerPop 4.x GLV proposal: *(breaking)* -** Adopted `undici` as a pinned dependency that ships a default dispatcher built from the discrete connection options below. -** Added `maxConnections` (default 128), which caps concurrent connections per origin where it was previously uncapped. *(breaking)* -** Added `readTimeoutMillis`, a per-read idle (body) timeout in milliseconds applied to the default dispatcher. -** Added `maxResponseHeaderBytes`, the maximum size of the response headers in bytes applied to the default dispatcher. -** Added `keepAliveTimeMillis` (default 30000), the idle time in milliseconds before TCP keep-alive probes begin, applied to the default dispatcher via a `SO_KEEPALIVE` connector; set to `0` to disable. -** Added `proxy`, which routes requests through an undici `ProxyAgent`. -** Added `compression`, a `'none'`/`'deflate'` string union defaulting to `'deflate'` (on). *(breaking)* -** Added `batchSize` (default 64), a connection-level default that fills a request's `batchSize` when it is left unset. -** Added a connection-level `bulkResults` default, applied to every request unless overridden per-request. -** Added `logger`, a logger object or callback (logging is disabled when unset). -** Removed the `ca`, `cert`, `pfx`, `rejectUnauthorized`, and `agent` options; TLS is now configured via the Node/undici runtime and the HTTP agent is owned by the driver's dispatcher. *(breaking)* -** Removed the standalone `headers` option; set custom headers via an interceptor instead. +* 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. -* 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`. * 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/upgrade/release-4.x.x.asciidoc b/docs/src/upgrade/release-4.x.x.asciidoc index 318a66b09d5..e2edc0bd98e 100644 --- a/docs/src/upgrade/release-4.x.x.asciidoc +++ b/docs/src/upgrade/release-4.x.x.asciidoc @@ -32,250 +32,80 @@ complete list of all the modifications that are part of this release. === Upgrading for Users -==== Standardizing JavaScript Connection Options - -TinkerPop 4.x standardizes connection option names and defaults across the GLVs. In `gremlin-javascript`, the driver -now adopts `undici` as a pinned dependency that ships a default dispatcher, several options have been removed -(including the standalone `headers` option), and a number of new options have been added. The notes below describe -the JavaScript changes. See <> for the equivalent changes in the other -drivers. - -Removed options. The following option has been removed: - -- `headers` has been removed. Set custom headers via an interceptor instead, e.g. -`interceptors: (req) => { req.headers['X-Custom'] = 'value'; }`. Because auth runs as the last interceptor, SigV4 still -signs over headers added this way. - -Behavior changes. These change runtime behavior on upgrade, even if you do not change your configuration: - -- `compression` now defaults to `'deflate'` (on), so the driver sends `Accept-Encoding: deflate` by default. It is a -`'none'`/`'deflate'` string union. Set `'none'` to disable it. -- `maxConnections` now caps concurrent connections per origin at 128 by default, where the number of connections was -previously uncapped. - -New options: - -- `readTimeoutMillis`: a per-read idle (body) timeout in milliseconds applied to the default dispatcher (undici -`bodyTimeout`). It resets per chunk, so it is safe for streaming. -- `maxResponseHeaderBytes`: the maximum size of the response headers in bytes applied to the default dispatcher (undici -`maxHeaderSize`). -- `keepAliveTimeMillis` (default 30000): the idle time in milliseconds before TCP keep-alive probes begin, applied to -the default dispatcher via a `SO_KEEPALIVE` connector. Set 0 to disable. -- `proxy`: an HTTP proxy URI that routes requests through an undici `ProxyAgent`. -- `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. -- `logger`: a logger object (with `debug`/`info`/`warn`/`error` methods) or a `(level, message, ...args)` callback. -Logging is disabled when unset. - -The `ca`, `cert`, `pfx`, `rejectUnauthorized`, and `agent` options have been removed. TLS is now configured through the -Node/undici runtime (for example `NODE_EXTRA_CA_CERTS` or `NODE_TLS_REJECT_UNAUTHORIZED`) and the HTTP agent is owned -by the driver's dispatcher. *(breaking)* +==== 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 .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`. - -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 From 1accad3b15d7250fffb436f389403745b1e7a05b Mon Sep 17 00:00:00 2001 From: Guian Gumpac Date: Tue, 30 Jun 2026 16:27:22 -0700 Subject: [PATCH 6/7] Fix gremlin-mcp --- gremlin-js/gremlin-mcp/src/gremlin/connection.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) 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 From a6efd49f8c0e6414782f96c83b697eaa054d22a2 Mon Sep 17 00:00:00 2001 From: Guian Gumpac Date: Thu, 2 Jul 2026 09:43:54 -0700 Subject: [PATCH 7/7] Use fetch from undici package rather that node's fetch --- .../gremlin-javascript/lib/driver/connection.ts | 4 ++-- .../lib/driver/dispatcher.browser.ts | 9 +++++++++ .../gremlin-javascript/lib/driver/dispatcher.ts | 13 ++++++++++++- .../gremlin-javascript/test/unit/connection-test.js | 9 +++++---- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/gremlin-js/gremlin-javascript/lib/driver/connection.ts b/gremlin-js/gremlin-javascript/lib/driver/connection.ts index f8edaf4b44d..4a673d8b890 100644 --- a/gremlin-js/gremlin-javascript/lib/driver/connection.ts +++ b/gremlin-js/gremlin-javascript/lib/driver/connection.ts @@ -33,7 +33,7 @@ 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 } from './dispatcher.js'; +import { buildDispatcher, httpFetch } from './dispatcher.js'; import { Logger, LoggerCallback, normalizeLogger } from './logger.js'; const responseStatusCode = { @@ -321,7 +321,7 @@ export default class Connection extends EventEmitter { this._log('debug', `Sending ${httpRequest.method} request to ${httpRequest.url}`); - return fetch(httpRequest.url, { + return httpFetch.fetch(httpRequest.url, { method: httpRequest.method, headers: httpRequest.headers, body: httpRequest.body, diff --git a/gremlin-js/gremlin-javascript/lib/driver/dispatcher.browser.ts b/gremlin-js/gremlin-javascript/lib/driver/dispatcher.browser.ts index b09d34d1ef6..8f80cbb014f 100644 --- a/gremlin-js/gremlin-javascript/lib/driver/dispatcher.browser.ts +++ b/gremlin-js/gremlin-javascript/lib/driver/dispatcher.browser.ts @@ -68,3 +68,12 @@ export function buildDispatcher(options: DispatcherOptions = {}): undefined { } 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 index e1e5e2183e9..7e323aff77a 100644 --- a/gremlin-js/gremlin-javascript/lib/driver/dispatcher.ts +++ b/gremlin-js/gremlin-javascript/lib/driver/dispatcher.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Agent, ProxyAgent, buildConnector, Dispatcher } from 'undici'; +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; @@ -111,3 +111,14 @@ export function buildDispatcher(options: DispatcherOptions = {}): Dispatcher { 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/test/unit/connection-test.js b/gremlin-js/gremlin-javascript/test/unit/connection-test.js index e674cd823a7..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 = {}) {