Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow adding ServerInterceptors to specific services and methods #2096

Merged
merged 12 commits into from
Nov 13, 2024

Conversation

gjcairo
Copy link
Collaborator

@gjcairo gjcairo commented Oct 16, 2024

Motivation

We want to allow users to customise the RPCs a registered interceptor should apply to on the server:

  • Intercept all requests
  • Intercept requests only meant for specific services
  • Intercept requests only meant for specific methods

Modifications

This PR adds a new ServerInterceptorPipelineOperation type that allows users to specify what the target of the interceptor should be.
Existing APIs accepting [any ServerInterceptor] have been kept, but new initialisers taking [ServerInterceptorPipelineOperation] instead have been added.

Result

Users can have more control over to which requests interceptors are applied.

@gjcairo gjcairo added ⚠️ semver/major Breaks existing public API. v2 A change for v2 labels Oct 16, 2024
@gjcairo gjcairo requested a review from glbrntt October 16, 2024 18:09
Copy link
Collaborator

@glbrntt glbrntt left a comment

Choose a reason for hiding this comment

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

Thanks Gus – I think this is broadly the right approach and I like how the user has full control over the ordering of global/service/method specific interceptors. I have a few pieces of higher level feedback:

  • I don't think we should propagate the ServerInterceptorTarget right down into the server executor. It couples the surface API (i.e. configuration) with the internals more than is necessary, so rather than pushing that all the way down I think the server should pre-compute the array of interceptors for each method and pass that down to the executor (instead of the array of targets). This also means we can avoid working out which interceptors to apply when each interceptor is called (which is potentially quite expensive).
  • I think there's value in making the common case (interceptors apply to all services) as easy as it currently is, so the server should likely have an overload for the interceptors (i.e. one should take [any ServerInterceptor] and apply to everything) and the other variant should all users to customise the setup a bit more.

I think it might also make sense to not think of "targets" but operations on an interceptor pipeline. It could be something like:

let server = GRPCServer(
  ...
  interceptorPipeline: [
    // Add to all
    .add(LoggingInterceptor()),
    // Add to foo service
    .add(AuthInterceptor(), services: [foo]),
    // Add to foo service _and_ the bar method 
    .add(Blah(), services: [foo], methods: [bar]),
    // Add to the bar method
    .add(BlahBlah(), methods: [bar])
  ]
)

This also lets us leave open the door to adding operations that don't add an interceptor for a particular service/method as well. (Maybe "include" is better terminology here than "add"?)

@gjcairo
Copy link
Collaborator Author

gjcairo commented Oct 28, 2024

Thanks for the review George, you bring up good points. A couple of things:

I don't think we should propagate the ServerInterceptorTarget right down into the server executor.

Yeah, I went a bit back and forth on this one. I had originally precomputed the array of interceptors, but it felt like a lot of unnecessary new arrays being passed around. However I'm not actually sure performance would be affected - I didn't instrument it before making the change. I'm happy to make the change.

I think there's value in making the common case (interceptors apply to all services) as easy as it currently is

Agreed, good idea.

Also noted about naming. I'll give it a bit more thought.

@gjcairo gjcairo requested a review from glbrntt October 28, 2024 22:32
@glbrntt
Copy link
Collaborator

glbrntt commented Oct 29, 2024

Thanks for the review George, you bring up good points. A couple of things:

I don't think we should propagate the ServerInterceptorTarget right down into the server executor.

Yeah, I went a bit back and forth on this one. I had originally precomputed the array of interceptors, but it felt like a lot of unnecessary new arrays being passed around. However I'm not actually sure performance would be affected - I didn't instrument it before making the change. I'm happy to make the change.

It's potentially quite expensive to do the filtering (depends on how many interceptors are registered and the rules for which services/methods they apply to), and this is done each time the RPC is called. It's especially bad if none of the interceptors apply to the RPC being handled!

I think we should precompute and store an array of interceptors for each method registered with the router. That way we avoid repeating the work each time an RPC is executed.

If we do this carefully we can arrange for there to only be one array allocation per unique interceptor pipeline (rather than one array allocation per method).

Sources/GRPCCore/Call/Server/RPCRouter.swift Outdated Show resolved Hide resolved
Sources/GRPCCore/GRPCServer.swift Outdated Show resolved Hide resolved
Comment on lines 217 to 218
router: RPCRouter,
interceptorPipeline: [ServerInterceptorOperation]
Copy link
Collaborator

Choose a reason for hiding this comment

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

Previously the interceptors were passed to the router when handle is called. This meant that at this point in the code – i.e. when the router is passed to the server – that the router was fully configured and the caller was responsible for configuring it.

In the other API, where the caller passes services and interceptors to the server, the server was responsible for configuring the router.

With the router now accepting interceptors the lines are blurred: it's not clear who is responsible for configuring the router. In this case it's partly the caller and partly the server. I think this distinction should be clearer.

I see a few options:

  1. Remove the ability to create the server with a router and interceptors and make the caller responsible for configuring the router entirely. (If the API where they pass it into the server.)
  2. Stop storing the interceptors on the router and continue storing them on the server. This would require the server to lookup the interceptors for each RPC and pass them into the router (which is similar to the existing setup).

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good point. I honestly think that 1 is the right approach here, since, in the init where the router is passed, we are already assuming it's set up properly (with e.g. each service's methods registered).

Copy link
Collaborator

Choose a reason for hiding this comment

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

Great, I'm happy with (1)

Sources/GRPCCore/GRPCServer.swift Outdated Show resolved Hide resolved
/// Returns whether this ``ServerInterceptorOperation`` applies to the given `descriptor`.
/// - Parameter descriptor: A ``MethodDescriptor`` for which to test whether this interceptor applies.
/// - Returns: `true` if this interceptor applies to the given `descriptor`, or `false` otherwise.
public func applies(to descriptor: MethodDescriptor) -> Bool {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Does this need to be public?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Only if we want to keep RPCRouter/registerInterceptors inlinable.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Okay, in that case we should prefix it with an underscore

}

/// Get the ``ServerInterceptor`` associated with this ``ServerInterceptorOperation``.
public var interceptor: any ServerInterceptor {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Does this need to be public?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Only if we want to keep RPCRouter/registerInterceptors inlinable.

@gjcairo gjcairo force-pushed the service-specific-server-interceptors branch from ee2ea0a to 817291a Compare November 11, 2024 18:07
@gjcairo gjcairo requested a review from glbrntt November 11, 2024 18:07
@@ -142,6 +144,16 @@ public struct RPCRouter: Sendable {
public mutating func removeHandler(forMethod descriptor: MethodDescriptor) -> Bool {
return self.handlers.removeValue(forKey: descriptor) != nil
}

@inlinable
mutating func registerInterceptors(pipeline: [ServerInterceptorOperation]) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Now we have two APIs for configuring the router:

  1. The server configures it (caller passes in services and interceptor pipeline)
  2. The caller configures the router and passes it to the server

I think we need to make this method public so that users can register interceptors using path (2).

One knock on from this is that we either need to document very clearly that the ordering of registerHandler and registerInterceptors are important, or we force users to init the router with the interceptor pipeline.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good point. I think there's value in letting users register the interceptors manually. I've added docs - let me know if you think they're not enough.

@glbrntt glbrntt added ⚠️ semver/major Breaks existing public API. and removed ⚠️ semver/major Breaks existing public API. labels Nov 12, 2024
@gjcairo gjcairo requested a review from glbrntt November 12, 2024 21:31
Copy link
Collaborator

@glbrntt glbrntt left a comment

Choose a reason for hiding this comment

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

Thanks Gus, this looks great. The docs check and formatting checks are failing though so those need fixing up before we can merge this.

@glbrntt
Copy link
Collaborator

glbrntt commented Nov 13, 2024

API break expected:

1 breaking change detected in GRPCCore:
  💔 API breakage: constructor GRPCServer.init(transport:router:interceptors:) has been removed

@glbrntt glbrntt merged commit c3f09df into grpc:main Nov 13, 2024
42 of 45 checks passed
@gjcairo gjcairo deleted the service-specific-server-interceptors branch November 13, 2024 15:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
⚠️ semver/major Breaks existing public API. v2 A change for v2
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants