Skip to content

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

Open
olavloite wants to merge 3 commits into
grpc:masterfrom
olavloite:sync-fast-path
Open

olavloite wants to merge 3 commits into
grpc:masterfrom
olavloite:sync-fast-path

Conversation

@olavloite

@olavloite olavloite commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Filters no longer have to return a Promise. If a filter can do its work
right away, it just returns the result, and the call keeps going without
waiting for a microtask. Only filters that actually do async work, like
gzip compression or xds fault injection, still return a Promise.

This changes the experimental Filter API: filters now receive a plain
value instead of a Promise. The xds fault injection filter is updated
accordingly. Existing filters that just await their input keep working.

Long Version

Previously, FilterStack and CompressionFilter always ran sendMetadata,
sendMessage and receiveMessage through async functions and Promise
chains, even when no compression was used and no asynchronous filter
was present. Every uncompressed RPC therefore allocated several
intermediate Promises and closures and went through the microtask queue
for metadata filtering, request framing and response deframing.
The Filter methods sendMetadata, sendMessage and receiveMessage now take
their input value directly and return either the result or a thenable
(T | PromiseLike). This is a change to the experimental Filter API.
Filters that simply await their input keep working.

  • Synchronous metadata and uncompressed message handling:
    CompressionFilter sets its headers synchronously, and
    CompressionHandler.writeMessage and readMessage return a Buffer
    directly when no compression or decompression is needed (identity
    encoding, or WriteFlags.NoCompress). A Promise is only returned when
    zlib is actually used.
  • Synchronous BaseFilter defaults: the default implementations are
    plain pass-throughs, so filters that don't override a method add no
    allocations.
  • FilterStack calls each filter directly and only starts chaining once
    a filter returns a thenable. Results are detected with an isThenable
    check, so promises from other realms and custom thenables work too.
  • Shared IDENTITY_HANDLER singleton: IdentityHandler has no state, so
    CompressionFilter reuses one module-level instance instead of
    allocating two per call.
  • Synchronous child call start on warm channels: when the channel
    config is already resolved and metadata filters are synchronous,
    ResolvingCall starts the child call and forwards the first message
    and halfClose in the same turn.
    Behavior is otherwise unchanged:
  • When any filter returns a thenable (gzip/deflate compression or an
    async filter such as xds fault injection), ResolvingCall still sets
    writeFilterPending and readFilterPending to hold halfClose and
    onReceiveStatus until the filter finishes. Status is still delivered
    via process.nextTick in outputStatus.
  • The xds FaultInjectionFilter is updated for the new signature and
    keeps its existing behavior. RouterFilter needs no changes.
    Minor housekeeping:
  • ResolvingCall and LoadBalancingCall set pendingMessage to null once
    the queued message has been forwarded, so the request buffer is not
    kept for the rest of the call.
  • ResolvingCall checks if (this.ended) return; when asynchronous
    metadata or message filters resolve, so a cancelled call does not
    start a child call or forward in-flight messages.

…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.

@murgatroid99 murgatroid99 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The *MaybeSync methods you added here are effectively strictly more general than the existing methods, and the Filter and FilterStack APIs are experimental, meaning that breaking changes can be made to them in new minor versions. In addition, having both and making the *MaybeSync methods optional increases the complexity of the new code. So, I would prefer to just replace the existing methods. That would require changes to the other filters, and to packages/grpc-js-xds/src/http-filter/fault-injection-filter.ts, but they should be pretty trivial.

Comment thread packages/grpc-js/src/filter-stack.ts Outdated
Comment on lines +45 to +50
result =
result instanceof Promise
? result.then(resolvedMetadata =>
filter.sendMetadataMaybeSync!(resolvedMetadata)
)
: filter.sendMetadataMaybeSync(result);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The result here should also need the same normalization mentioned in the comment in the last case, because it can similarly return a Promise<Metadata>. However, we can't just unconditionally wrap the result in Promise.resolve here, because that would reverse most of the gains we get from using Metadata objects directly. Also, any check for whether the result needs to be normalized could instead be used in place of the result instanceof Promise line here.

I think the actual solution here is to replace result instanceof Promise with a more generic isThenable(result) check. The same applies to the other similar functions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

…ter API

Address review feedback on the synchronous filter fast path.

Instead of adding separate *MaybeSync methods next to the existing
Promise-based ones, the Filter methods themselves now take a plain value
and may return either the result directly or a thenable when they need to
do asynchronous work. BaseFilter's methods are now simple synchronous
pass-throughs.

Filter results are now detected with an isThenable check instead of
`instanceof Promise`, so the result of every filter is handled the same
way, including the last one in the stack. Promises from other realms and
custom thenables are handled correctly as well.

The special case that skipped BaseFilter pass-through methods in
FilterStack has been removed, as it is no longer needed now that those
methods are synchronous.

This is a breaking change to the experimental Filter API: filters now
receive the value itself instead of a Promise of it. The fault injection
filter in grpc-js-xds has been updated to match. Filters that simply
`await` their input keep working.
@olavloite

Copy link
Copy Markdown
Contributor Author

The *MaybeSync methods you added here are effectively strictly more general than the existing methods, and the Filter and FilterStack APIs are experimental, meaning that breaking changes can be made to them in new minor versions. In addition, having both and making the *MaybeSync methods optional increases the complexity of the new code. So, I would prefer to just replace the existing methods. That would require changes to the other filters, and to packages/grpc-js-xds/src/http-filter/fault-injection-filter.ts, but they should be pretty trivial.

Done

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