Skip to content

grpc-js: Backport performance and stability improvements to 1.14.x branch - #3107

Merged
murgatroid99 merged 12 commits into
grpc:@grpc/grpc-js@1.14.xfrom
olavloite:backport-perf-to-1.14.x
Oct 7, 2026
Merged

murgatroid99 merged 12 commits into
grpc:@grpc/grpc-js@1.14.xfrom
olavloite:backport-perf-to-1.14.x

Conversation

@olavloite

Copy link
Copy Markdown
Contributor

Backport of recent performance optimizations and supporting test/CI fixes from master to the @grpc/grpc-js@1.14.x branch for the upcoming patch release.

All 12 changes have already been reviewed and merged into master:

Performance Optimizations

Test and CI Fixes

…c#3092)

Make ResolvingCall.deadlineTimer nullable and initialize it to null instead of allocating a throwaway setTimeout(() => {}, 0) on every RPC.

- Initialize deadlineTimer to null and add null checks before clearTimeout.
- Set deadlineTimer to null once cleared or when the deadline handler fires.
- Add a test verifying that calls without deadlines do not schedule timers.
…c#3093)

Read grpc-status and grpc-message directly from trailers using metadata.get() instead of calling metadata.getMap().

Calling metadata.getMap() on every completed RPC allocates a map object, iterates over all trailers, and clones binary buffers (such as grpc-status-details-bin) that are immediately thrown away. Reading the two needed headers directly avoids these allocations on the response hot path.
…rs (grpc#3099)

Most gRPC metadata headers contain only a single value (such as authorization tokens, tracing headers, and request IDs). Previously, `toHttp2Headers` always called `.map()`, allocating a temporary array for every header key on every RPC.

For single-value headers, we now assign the formatted value directly to the header object instead of wrapping it in an array. This eliminates unnecessary allocations and reduces garbage collection pressure on the RPC hot path.
The copy-protos script (run during `npm run prepare`) copies external proto definitions from grpc-js-xds into `packages/grpc-js/proto/protoc-gen-validate` and `packages/grpc-js/proto/xds`.

Add both directories to .gitignore so these generated dependencies do not show up as untracked files or get accidentally committed.
…g paths (grpc#3090)

Store timestamps as epoch milliseconds (Date.now()) instead of allocating Date objects on the request path and channelz counters:
- Call layers (ResolvingCall, RetryingCall, LoadBalancingCall): store call start and resolution times as numbers; construct Date instances only when formatting deadline error messages or when trace logging is enabled.
- Channelz call tracker and HTTP/2 transport: record last call started, message sent, and message received timestamps as numbers.
- Channel activity tracking: record channel idle timestamp as a number.
- Server timeout handling: calculate incoming call deadlines with Date.now() + timeout instead of allocating a Date object.
- dateToProtoTimestamp: support numeric millisecond timestamps in addition to Date objects, and use Math.floor() to avoid 32-bit integer overflow beyond year 2038.
- formatDateDifference: accept numeric timestamps as well as Date instances.
- Update channelz and deadline tests for numeric timestamps.
Every Metadata instance previously allocated a Map for opaqueData in its constructor, even though opaqueData is only used in specific scenarios like caching parsed ORCA load reports on trailers.

On high-throughput RPC paths where multiple Metadata instances are created per call, this eager allocation resulted in unnecessary heap allocation and GC pressure.

Changes:
- Make Metadata.prototype.opaqueData optional and lazily instantiate the Map on the first setOpaque() call.
- Use optional chaining in getOpaque() when reading from opaqueData.
- Preserve existing clone() and constructor behavior to maintain exact compatibility.
- Add unit tests covering lazy allocation, key updates, value types, and cloned instance behavior.
Fix two test failures when running on Windows:
- Increase the timeout for the nonexistent domain test on Windows to allow sequential DNS lookup failures to complete.
- Skip the Unix Domain Socket (UDS) idle timer test on Windows where UDS file paths are not supported.
…3097)

Cache the precomputed serviceUrl used by call credentials in a bounded two-level cache on InternalChannel instead of re-parsing the method path, splitting the host, and constructing a new template string on every RPC.

- Add zero-allocation `extractServiceName` and `computeServiceUrl` helpers in `uri-parser.ts`.
- Introduce a bounded two-level cache (`serviceUrlCache: Map<string, Map<string, string>>`) on `InternalChannel`, capped at 5 hosts and 100 methods per host to strictly bound memory while achieving zero-allocation lookups on cache hits.
- Clear `serviceUrlCache` when the channel is closed.
- Update `LoadBalancingCall` and `SingleSubchannelChannel` to use the shared URL computation and caching.
grpc#3098)

On every RPC, the default config selector parsed the method name by splitting the string, filtering empty segments, and looping through method config match levels to find the appropriate configuration. In high-throughput clients, this performed repetitive string splitting, array allocations, and lookups on the hot path.

This change:
1. Caches the resolved MethodConfig by method name in a Map within getDefaultConfigSelector, avoiding repeated string splitting and search loops on cache hits.
2. Bounds the cache to 100 method entries to keep memory usage fixed.
3. Clears the cache in unref() when channel resolution updates or the channel closes.
4. Returns fresh CallConfig containers for each RPC, preserving call isolation for pickInformation and dynamic filters.
…ial attempt (grpc#3101)

RetryingCall.startNewAttempt() previously cloned this.initialMetadata on every attempt, even when previousAttempts was 0 and no grpc-previous-rpc-attempts header needed to be added. Because LoadBalancingCall never mutates this.metadata and always clones it in doPick(), the extra clone in RetryingCall on the initial attempt was redundant.

Only clone this.initialMetadata in RetryingCall.startNewAttempt() when previousAttempts > 0.
…rpc#3104)

In test-weighted-round-robin, test traffic was issued immediately against freshly
created clients with aggressive 100ms per-call deadlines. On slower or heavily
loaded CI environments (such as Windows Kokoro runners), cold-start connection
establishment, name resolution, and picker readiness frequently exceeded 100ms,
triggering intermittent DEADLINE_EXCEEDED ("Waiting for LB pick") failures.
Additionally, blind initial traffic could reach only the first backend before
the second finished connecting, leaving the second backend's metrics unrecorded.
Furthermore, executing 50 sequential RPC round-trips combined with arbitrary
sleep timers pushed total execution time beyond Mocha's default 2-second timeout
on Windows VMs (often failing at ~2.005s).

This change stabilizes the tests by:
1. Ensuring the channel reaches readiness and that both backends have responded
   to initial traffic before measurement begins, guaranteeing that connections
   and initial metric tracking are established across all endpoints.
2. Synchronizing deterministically against picker updates rather than relying on
   fixed sleep delays, and executing measurement RPCs concurrently over HTTP/2
   so the suite runs in ~1s while preserving the expected weight distribution.
3. Increasing the test timeout to provide sufficient headroom against VM
   scheduling jitter on Windows CI.
… hot path (grpc#3100)

Previously, every RPC executed through LoadBalancingCall composed per-call and subchannel CallCredentials and called generateMetadata(), even when both sides were EmptyCallCredentials. In addition:
- Composing any CallCredentials with EmptyCallCredentials on the right-hand side wrapped them in a new ComposedCallCredentials instance.
- InsecureChannelCredentialsImpl allocated a new EmptyCallCredentials instance on every getCallCredentials() call when no call credentials were provided.
- EmptyCallCredentials.generateMetadata() allocated a new Metadata instance and Promise on every RPC only to merge an empty map.

This change:
- Treats EmptyCallCredentials as a two-sided identity in compose(), returning `this` directly when composing with an empty instance.
- Evaluates the fallback CallCredentials.createEmpty() once per connector in InsecureChannelCredentialsImpl._createSecureConnector().
- Short-circuits metadata generation and merging in LoadBalancingCall when the composed CallCredentials is empty, reusing a pre-resolved Promise.
@murgatroid99

Copy link
Copy Markdown
Member

Don't worry about the Windows failure. A different fix for that is on master but never got ported to this branch.

@murgatroid99
murgatroid99 merged commit 025cc24 into grpc:@grpc/grpc-js@1.14.x Oct 7, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants