Repository navigation
Add SDK-native gRPC server hosting on a borrowed Restlet listener #1628
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
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
f40cd74
Add SDK-native gRPC server hosting on a borrowed Restlet listener
jlouvel 495dbb8
Free gRPC server call slots in the thread that ends the call
jlouvel c4f42c8
Harden the gRPC server against stalled peers and failed delivery
jlouvel 3b8b324
Keep the gRPC server adapter's stop synchronized like its parent
jlouvel fb150cf
Keep gRPC callbacks off the HTTP dispatch and onSent off the event loop
jlouvel 346144d
Bound gRPC callbacks after cancellation and claim calls on delivery
jlouvel 9d11aec
Relay raw gRPC metadata and report truncated responses as undelivered
jlouvel c6c2c0f
Isolate SDK timers and report delivery before the peer sees the response
jlouvel File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
314 changes: 314 additions & 0 deletions
314
org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/GrpcServerAdapter.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,314 @@ | ||
| /** | ||
| * Copyright 2005-2026 Qlik | ||
| *<p> | ||
| * The content of this file is subject to the terms of the Apache 2.0 open | ||
| * source license available at https://www.opensource.org/licenses/apache-2.0 | ||
| *<p> | ||
| * Restlet is a registered trademark of QlikTech International AB. | ||
| */ | ||
| package org.restlet.ext.grpc; | ||
|
|
||
| import io.grpc.ServerInterceptor; | ||
| import io.grpc.ServerServiceDefinition; | ||
| import io.grpc.internal.ServerImplBuilder; | ||
| import java.io.IOException; | ||
| import java.time.Duration; | ||
| import java.util.List; | ||
| import java.util.concurrent.CopyOnWriteArrayList; | ||
| import java.util.concurrent.Executor; | ||
| import java.util.concurrent.ExecutorService; | ||
| import java.util.concurrent.Executors; | ||
| import java.util.concurrent.TimeUnit; | ||
| import org.restlet.Context; | ||
| import org.restlet.Request; | ||
| import org.restlet.Response; | ||
| import org.restlet.Restlet; | ||
| import org.restlet.ext.grpc.internal.RestletServerHost; | ||
| import org.restlet.routing.Router; | ||
| import org.restlet.routing.Template; | ||
|
|
||
| /** | ||
| * Socketless grpc-java server on a borrowed Restlet HTTP/2 listener. | ||
| * | ||
| * <p>Supports unary requests and server-streamed responses, with finite message, queue, concurrency | ||
| * and duration budgets. Does not implement TLS or physical connection controls. The host | ||
| * application owns the HTTP listener and any supplied callback executor. | ||
| * | ||
| * <p>Register services before {@link #start()}, attach them to a router with {@link #attachTo}, and | ||
| * configure the host listener for plaintext HTTP/2. This adapter is single-use. Stopping it cancels | ||
| * active calls, waits up to the shutdown timeout for SDK and owned callback termination, and shuts | ||
| * down only its own executor and timer, not the listener or an executor supplied with {@link | ||
| * #setExecutor}. | ||
| */ | ||
| public final class GrpcServerAdapter extends Restlet { | ||
| private final List<ServerServiceDefinition> services = new CopyOnWriteArrayList<>(); | ||
| private final List<ServerInterceptor> interceptors = new CopyOnWriteArrayList<>(); | ||
|
|
||
| /** | ||
| * Guards the configuration and lifecycle state. The adapter's own monitor, held by {@link | ||
| * #start()} and {@link #stop()} like any Restlet's, is taken first. | ||
| */ | ||
| private final Object lock = new Object(); | ||
|
|
||
| private int maxMessageBytes = 1024 * 1024; | ||
| private int maxQueuedBytes = 256 * 1024; | ||
| private int maxConcurrentCalls = 128; | ||
| private Duration maxCallDuration = Duration.ofSeconds(5); | ||
| private Duration shutdownTimeout = Duration.ofSeconds(5); | ||
| private Executor executor; | ||
| private ExecutorService ownedExecutor; | ||
| private io.grpc.Server sdk; | ||
| private RestletServerHost host; | ||
| private boolean configured; | ||
|
|
||
| public GrpcServerAdapter() { | ||
| this(new Context()); | ||
| } | ||
|
|
||
| public GrpcServerAdapter(Context context) { | ||
| super(context); | ||
| } | ||
|
|
||
| public List<ServerServiceDefinition> getServices() { | ||
| return services; | ||
| } | ||
|
|
||
| public List<ServerInterceptor> getInterceptors() { | ||
| return interceptors; | ||
| } | ||
|
|
||
| public void setMaxMessageBytes(int value) { | ||
| synchronized (lock) { | ||
| configurable(); | ||
| if (value <= 0 || value > Integer.MAX_VALUE - 6) { | ||
| throw new IllegalArgumentException("A positive finite message budget is required"); | ||
| } | ||
| maxMessageBytes = value; | ||
| } | ||
| } | ||
|
|
||
| public int getMaxMessageBytes() { | ||
| synchronized (lock) { | ||
| return maxMessageBytes; | ||
| } | ||
| } | ||
|
|
||
| public void setMaxQueuedBytes(int value) { | ||
| synchronized (lock) { | ||
| configurable(); | ||
| if (value < 16384) { | ||
| throw new IllegalArgumentException("Queue budget must be at least 16384 bytes"); | ||
| } | ||
| maxQueuedBytes = value; | ||
| } | ||
| } | ||
|
|
||
| public int getMaxQueuedBytes() { | ||
| synchronized (lock) { | ||
| return maxQueuedBytes; | ||
| } | ||
| } | ||
|
|
||
| public void setMaxConcurrentCalls(int value) { | ||
| synchronized (lock) { | ||
| configurable(); | ||
| if (value <= 0) { | ||
| throw new IllegalArgumentException("A positive call budget is required"); | ||
| } | ||
| maxConcurrentCalls = value; | ||
| } | ||
| } | ||
|
|
||
| public int getMaxConcurrentCalls() { | ||
| synchronized (lock) { | ||
| return maxConcurrentCalls; | ||
| } | ||
| } | ||
|
|
||
| public void setMaxCallDuration(Duration value) { | ||
| synchronized (lock) { | ||
| configurable(); | ||
| maxCallDuration = positive(value); | ||
| } | ||
| } | ||
|
|
||
| public Duration getMaxCallDuration() { | ||
| synchronized (lock) { | ||
| return maxCallDuration; | ||
| } | ||
| } | ||
|
|
||
| public void setShutdownTimeout(Duration value) { | ||
| synchronized (lock) { | ||
| configurable(); | ||
| shutdownTimeout = positive(value); | ||
| } | ||
| } | ||
|
|
||
| public Duration getShutdownTimeout() { | ||
| synchronized (lock) { | ||
| return shutdownTimeout; | ||
| } | ||
| } | ||
|
|
||
| public void setExecutor(Executor value) { | ||
| synchronized (lock) { | ||
| configurable(); | ||
| executor = java.util.Objects.requireNonNull(value, "executor"); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Returns the callback executor: the supplied one, the adapter-owned one once started, or null | ||
| * before start when none was supplied. | ||
| */ | ||
| public Executor getExecutor() { | ||
| synchronized (lock) { | ||
| return executor; | ||
| } | ||
| } | ||
|
|
||
| private static Duration positive(Duration value) { | ||
| if (value.isNegative() || value.isZero()) { | ||
| throw new IllegalArgumentException("A positive duration is required"); | ||
| } | ||
| return Duration.ofNanos(value.toNanos()); | ||
| } | ||
|
|
||
| /** Requires the configuration to be still open; called with {@link #lock} held. */ | ||
| private void configurable() { | ||
| if (configured) { | ||
| throw new IllegalStateException("Server adapter is single-use; configuration is fixed"); | ||
| } | ||
| } | ||
|
|
||
| /** Attaches gRPC service prefixes while leaving other routes on the same listener untouched. */ | ||
| public void attachTo(Router router) { | ||
| for (var service : List.copyOf(services)) { | ||
| router.attach( | ||
| "/" + service.getServiceDescriptor().getName() + "/", | ||
| this, | ||
| Template.MODE_STARTS_WITH); | ||
| } | ||
| } | ||
|
|
||
| @Override | ||
| public synchronized void start() throws Exception { | ||
| synchronized (lock) { | ||
| if (isStarted()) { | ||
| return; | ||
| } | ||
| configurable(); | ||
| for (var service : List.copyOf(services)) { | ||
| for (var method : service.getMethods()) { | ||
| var type = method.getMethodDescriptor().getType(); | ||
| if (type != io.grpc.MethodDescriptor.MethodType.UNARY | ||
| && type != io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING) { | ||
| throw new UnsupportedOperationException( | ||
| "Only unary and server-streaming methods are supported"); | ||
| } | ||
| } | ||
| } | ||
| configured = true; | ||
| host = | ||
| new RestletServerHost( | ||
| maxMessageBytes, maxQueuedBytes, maxConcurrentCalls, maxCallDuration); | ||
| if (executor == null) { | ||
| ownedExecutor = Executors.newVirtualThreadPerTaskExecutor(); | ||
| executor = ownedExecutor; | ||
| } | ||
| var builder = | ||
| new ServerImplBuilder( | ||
| (tracers, recorder) -> { | ||
| host.setTracers(tracers); | ||
| return host; | ||
| }) | ||
| .executor(executor) | ||
| .maxInboundMessageSize(maxMessageBytes); | ||
| List.copyOf(services).forEach(builder::addService); | ||
| List.copyOf(interceptors).forEach(builder::intercept); | ||
| // Added last, so outermost: it holds a call's callback slot around all of them. | ||
| builder.intercept(host.callbackBudget()); | ||
| try { | ||
| sdk = builder.build().start(); | ||
| super.start(); | ||
| } catch (IOException | RuntimeException e) { | ||
| host.close(); | ||
| if (ownedExecutor != null) { | ||
| ownedExecutor.shutdownNow(); | ||
| } | ||
| throw e; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @Override | ||
| public void handle(Request request, Response response) { | ||
| RestletServerHost current; | ||
| synchronized (lock) { | ||
| current = isStarted() ? host : null; | ||
| } | ||
| if (current == null) { | ||
| response.setStatus(org.restlet.data.Status.SERVER_ERROR_SERVICE_UNAVAILABLE); | ||
| return; | ||
| } | ||
| current.handle(request, response); | ||
| } | ||
|
|
||
| /** | ||
| * Stops the adapter. The waits only hold the adapter's monitor, not {@link #lock}, so callbacks | ||
| * finishing meanwhile may still use its accessors. | ||
| */ | ||
| @Override | ||
| public synchronized void stop() throws Exception { | ||
| io.grpc.Server server; | ||
| RestletServerHost stopping; | ||
| ExecutorService owned; | ||
| Duration timeout; | ||
| synchronized (lock) { | ||
| if (sdk == null) { | ||
| return; | ||
| } | ||
| server = sdk; | ||
| stopping = host; | ||
| owned = ownedExecutor; | ||
| timeout = shutdownTimeout; | ||
| sdk = null; | ||
| } | ||
| long started = System.nanoTime(); | ||
| // Cancelling calls may run listeners, so the timeout bounds it like the waits after it. | ||
| Thread cancelling = | ||
| Thread.ofVirtual() | ||
| .name("restlet-grpc-server-stop") | ||
| .start( | ||
| () -> { | ||
| server.shutdownNow(); | ||
| if (stopping != null) { | ||
| stopping.close(); | ||
| } | ||
| }); | ||
| try { | ||
| if (!cancelling.join(timeout) | ||
| || !server.awaitTermination( | ||
| Math.max(0, timeout.toNanos() - (System.nanoTime() - started)), | ||
| TimeUnit.NANOSECONDS)) { | ||
| throw new IllegalStateException("gRPC server shutdown timed out"); | ||
| } | ||
| if (owned != null) { | ||
| owned.shutdown(); | ||
| long remaining = timeout.toNanos() - (System.nanoTime() - started); | ||
| if (!owned.awaitTermination(Math.max(0, remaining), TimeUnit.NANOSECONDS)) { | ||
| throw new IllegalStateException("gRPC callback executor shutdown timed out"); | ||
| } | ||
| } | ||
| } catch (InterruptedException e) { | ||
| Thread.currentThread().interrupt(); | ||
| throw e; | ||
| } finally { | ||
| if (owned != null) { | ||
| owned.shutdownNow(); | ||
| } | ||
| super.stop(); | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.