Skip to content

perf(grpc-js): add synchronous fast path for FilterStack and CompressionFilter - #3103

Open
olavloite wants to merge 1 commit into
grpc:masterfrom
olavloite:sync-fast-path
Open

olavloite wants to merge 1 commit into
grpc:masterfrom
olavloite:sync-fast-path

Conversation

@olavloite

Copy link
Copy Markdown
Contributor

Previously, FilterStack and CompressionFilter always executed sendMetadata, sendMessage, and receiveMessage through async functions and Promise chains—even when no compression was used and no asynchronous filters were present in the stack. As a result, every uncompressed RPC had to allocate multiple intermediate Promises and closures and hop through the microtask queue for metadata filtering, request framing, and response deframing.

This change adds an optional synchronous fast path (sendMetadataMaybeSync, sendMessageMaybeSync, and receiveMessageMaybeSync) to Filter, FilterStack, CompressionFilter, and ResolvingCall.

  • Synchronous metadata and uncompressed message framing/deframing: CompressionHandler.writeMessage and readMessage now return a Buffer synchronously when no compression or decompression is required (such as identity encoding or when WriteFlags.NoCompress is set), only returning a Promise when zlib compression or decompression is actually needed.

  • Zero-allocation pass-through for BaseFilter methods: FilterStack skips methods inherited unchanged from BaseFilter (such as pass-through methods on RouterFilter) without allocating a Promise.

  • Shared IDENTITY_HANDLER singleton: IdentityHandler has no instance state, so CompressionFilter now reuses a module-level singleton instead of allocating two new IdentityHandler instances per call.

  • Synchronous child call start on warm channels: When channel configuration is already resolved and metadata filters are synchronous, ResolvingCall now starts the child RetryingCall and forwards the initial request message and halfClose in the same turn, avoiding the intermediate pendingMessage queuing in ResolvingCall and allowing LoadBalancingCall to flush the initial message and half-close together when call credentials resolve.

  • Full backward compatibility for existing filters and callers: The existing sendMetadata, sendMessage, and receiveMessage methods on Filter, BaseFilter, and FilterStack are preserved. When a legacy filter is present in the stack, FilterStack seamlessly transitions to Promise chaining for that filter and any subsequent filters in the pipeline.

  • Unchanged ordering and async guarantees: When any filter in the stack returns a Promise (for example, gzip or deflate compression, or a custom async filter), ResolvingCall continues to set writeFilterPending and readFilterPending to defer halfClose and onReceiveStatus until the pending filter finishes. Status delivery in outputStatus continues to use process.nextTick.

  • Minor housekeeping:

    • Clear this.pendingMessage = null in ResolvingCall and LoadBalancingCall once the queued message has been forwarded to the child call so the request buffer is not retained for the rest of the call.
    • Check if (this.ended) return; when asynchronous metadata or message filters resolve so cancelled calls do not start a child call or forward in-flight messages after cancellation.

…ionFilter

Previously, `FilterStack` and `CompressionFilter` always executed
`sendMetadata`, `sendMessage`, and `receiveMessage` through `async` functions
and `Promise` chains—even when no compression was used and no asynchronous
filters were present in the stack. As a result, every uncompressed RPC had to
allocate multiple intermediate `Promise`s and closures and hop through the
microtask queue for metadata filtering, request framing, and response
deframing.

This change adds an optional synchronous fast path (`sendMetadataMaybeSync`,
`sendMessageMaybeSync`, and `receiveMessageMaybeSync`) to `Filter`,
`FilterStack`, `CompressionFilter`, and `ResolvingCall`.

- **Synchronous metadata and uncompressed message framing/deframing**:
  `CompressionHandler.writeMessage` and `readMessage` now return a `Buffer`
  synchronously when no compression or decompression is required (such as
  `identity` encoding or when `WriteFlags.NoCompress` is set), only returning
  a `Promise` when `zlib` compression or decompression is actually needed.
- **Zero-allocation pass-through for `BaseFilter` methods**: `FilterStack`
  skips methods inherited unchanged from `BaseFilter` (such as pass-through
  methods on `RouterFilter`) without allocating a `Promise`.
- **Shared `IDENTITY_HANDLER` singleton**: `IdentityHandler` has no instance
  state, so `CompressionFilter` now reuses a module-level singleton instead of
  allocating two new `IdentityHandler` instances per call.
- **Synchronous child call start on warm channels**: When channel configuration
  is already resolved and metadata filters are synchronous, `ResolvingCall` now
  starts the child `RetryingCall` and forwards the initial request message and
  `halfClose` in the same turn, avoiding the intermediate `pendingMessage`
  queuing in `ResolvingCall` and allowing `LoadBalancingCall` to flush the
  initial message and half-close together when call credentials resolve.

- **Full backward compatibility for existing filters and callers**: The
  existing `sendMetadata`, `sendMessage`, and `receiveMessage` methods on
  `Filter`, `BaseFilter`, and `FilterStack` are preserved. When a legacy filter
  is present in the stack, `FilterStack` seamlessly transitions to `Promise`
  chaining for that filter and any subsequent filters in the pipeline.
- **Unchanged ordering and async guarantees**: When any filter in the stack
  returns a `Promise` (for example, `gzip` or `deflate` compression, or a
  custom async filter), `ResolvingCall` continues to set `writeFilterPending`
  and `readFilterPending` to defer `halfClose` and `onReceiveStatus` until the
  pending filter finishes. Status delivery in `outputStatus` continues to use
  `process.nextTick`.
- **Minor housekeeping**:
  - Clear `this.pendingMessage = null` in `ResolvingCall` and
    `LoadBalancingCall` once the queued message has been forwarded to the child
    call so the request buffer is not retained for the rest of the call.
  - Check `if (this.ended) return;` when asynchronous metadata or message
    filters resolve so cancelled calls do not start a child call or forward
    in-flight messages after cancellation.
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.

1 participant