Skip to content

Commit 24c8d61

Browse files
committed
core: introduce MirroringInterceptor for traffic shadowing
This adds a fire-and-forget interceptor that mirrors Unary and Streaming traffic to a secondary channel without blocking the primary call. Headers are copied and propagated safely, and the secondary call respects the lifecycle (halfClose, cancel) of the primary stream. Addresses the Java ClientInterceptor proposal discussed in #12448
1 parent 46a6cdf commit 24c8d61

2 files changed

Lines changed: 201 additions & 0 deletions

File tree

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/*
2+
* Copyright 2025 The gRPC Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.grpc.util;
18+
19+
import com.google.common.base.Preconditions;
20+
import io.grpc.CallOptions;
21+
import io.grpc.Channel;
22+
import io.grpc.ClientCall;
23+
import io.grpc.ClientInterceptor;
24+
import io.grpc.ForwardingClientCall;
25+
import io.grpc.Metadata;
26+
import io.grpc.MethodDescriptor;
27+
import java.util.concurrent.Executor;
28+
import java.util.logging.Level;
29+
import java.util.logging.Logger;
30+
31+
/**
32+
* A ClientInterceptor that mirrors calls to a shadow channel.
33+
* Designed to support Unary, Client-Streaming, Server-Streaming, and Bidi calls.
34+
*/
35+
public final class MirroringInterceptor implements ClientInterceptor {
36+
private static final Logger logger = Logger.getLogger(MirroringInterceptor.class.getName());
37+
38+
private final Channel mirrorChannel;
39+
private final Executor executor;
40+
41+
public MirroringInterceptor(Channel mirrorChannel, Executor executor) {
42+
this.mirrorChannel = Preconditions.checkNotNull(mirrorChannel, "mirrorChannel");
43+
this.executor = Preconditions.checkNotNull(executor, "executor");
44+
}
45+
46+
@Override
47+
public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
48+
MethodDescriptor<ReqT, RespT> method, CallOptions callOptions, Channel next) {
49+
50+
return new ForwardingClientCall.SimpleForwardingClientCall<ReqT, RespT>(
51+
next.newCall(method, callOptions)) {
52+
53+
private ClientCall<ReqT, RespT> mirrorCall;
54+
55+
@Override
56+
public void start(Listener<RespT> responseListener, Metadata headers) {
57+
// 1. Capture and copy headers immediately (thread-safe for the executor)
58+
final Metadata mirrorHeaders = new Metadata();
59+
mirrorHeaders.merge(headers);
60+
61+
executor.execute(() -> {
62+
try {
63+
// 2. Initialize the shadow call once per stream
64+
mirrorCall = mirrorChannel.newCall(method, callOptions);
65+
mirrorCall.start(new ClientCall.Listener<RespT>() {}, mirrorHeaders);
66+
} catch (Exception e) {
67+
logger.log(Level.WARNING, "Failed to start mirror call", e);
68+
}
69+
});
70+
super.start(responseListener, headers);
71+
}
72+
73+
@Override
74+
public void sendMessage(ReqT message) {
75+
executor.execute(() -> {
76+
if (mirrorCall != null) {
77+
try {
78+
mirrorCall.sendMessage(message);
79+
} catch (Exception e) {
80+
logger.log(Level.WARNING, "Mirroring message failed", e);
81+
}
82+
}
83+
});
84+
super.sendMessage(message);
85+
}
86+
87+
@Override
88+
public void halfClose() {
89+
executor.execute(() -> {
90+
if (mirrorCall != null) {
91+
try {
92+
mirrorCall.halfClose();
93+
} catch (Exception e) {
94+
logger.log(Level.WARNING, "Mirroring halfClose failed", e);
95+
}
96+
}
97+
});
98+
super.halfClose();
99+
}
100+
101+
@Override
102+
public void cancel(String message, Throwable cause) {
103+
executor.execute(() -> {
104+
if (mirrorCall != null) {
105+
try {
106+
mirrorCall.cancel(message, cause);
107+
} catch (Exception e) {
108+
logger.log(Level.WARNING, "Mirroring cancel failed", e);
109+
}
110+
}
111+
});
112+
super.cancel(message, cause);
113+
}
114+
};
115+
}
116+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package io.grpc.inprocess;
2+
3+
import static org.junit.Assert.assertTrue;
4+
import io.grpc.*;
5+
import io.grpc.testing.GrpcCleanupRule;
6+
import org.junit.Rule;
7+
import org.junit.Test;
8+
import java.util.concurrent.CountDownLatch;
9+
import java.util.concurrent.TimeUnit;
10+
import java.util.concurrent.atomic.AtomicBoolean;
11+
import java.nio.charset.StandardCharsets;
12+
13+
public class MirroringInterceptorTest {
14+
@Rule public final GrpcCleanupRule grpcCleanup = new GrpcCleanupRule();
15+
16+
private static final MethodDescriptor.Marshaller<String> MARSHALLER = new MethodDescriptor.Marshaller<String>() {
17+
@Override public java.io.InputStream stream(String value) {
18+
return new java.io.ByteArrayInputStream(value.getBytes(StandardCharsets.UTF_8));
19+
}
20+
@Override public String parse(java.io.InputStream stream) { return "response"; }
21+
};
22+
23+
private final MethodDescriptor<String, String> method = MethodDescriptor.<String, String>newBuilder()
24+
.setType(MethodDescriptor.MethodType.UNARY)
25+
.setFullMethodName("test/Method")
26+
.setRequestMarshaller(MARSHALLER)
27+
.setResponseMarshaller(MARSHALLER)
28+
.build();
29+
30+
@Test
31+
public void unaryCallIsMirroredWithHeaders() throws Exception {
32+
CountDownLatch mirrorLatch = new CountDownLatch(1);
33+
Metadata.Key<String> testKey = Metadata.Key.of("test-header", Metadata.ASCII_STRING_MARSHALLER);
34+
AtomicBoolean mirrorHeaderVerified = new AtomicBoolean(false);
35+
36+
// 1. Setup Mirror Server - IMPORTANT: It must CLOSE the call
37+
String mirrorName = InProcessServerBuilder.generateName();
38+
grpcCleanup.register(InProcessServerBuilder.forName(mirrorName).directExecutor()
39+
.addService(ServerServiceDefinition.builder("test")
40+
.addMethod(method, (call, headers) -> {
41+
if ("shadow-value".equals(headers.get(testKey))) {
42+
mirrorHeaderVerified.set(true);
43+
}
44+
mirrorLatch.countDown();
45+
46+
// CRITICAL: Close the call so the channel can shut down
47+
call.sendHeaders(new Metadata());
48+
call.close(Status.OK, new Metadata());
49+
return new ServerCall.Listener<String>() {};
50+
}).build()).build().start());
51+
52+
// 2. Setup Primary Server - Also must CLOSE the call
53+
String primaryName = InProcessServerBuilder.generateName();
54+
grpcCleanup.register(InProcessServerBuilder.forName(primaryName).directExecutor()
55+
.addService(ServerServiceDefinition.builder("test")
56+
.addMethod(method, (call, headers) -> {
57+
call.sendHeaders(new Metadata());
58+
call.close(Status.OK, new Metadata());
59+
return new ServerCall.Listener<String>() {};
60+
}).build()).build().start());
61+
62+
ManagedChannel mirrorChannel = grpcCleanup.register(InProcessChannelBuilder.forName(mirrorName).build());
63+
ManagedChannel primaryChannel = grpcCleanup.register(InProcessChannelBuilder.forName(primaryName).build());
64+
65+
// Use direct executor to keep the mirror call on the same thread
66+
java.util.concurrent.Executor directExecutor = Runnable::run;
67+
68+
Channel interceptedChannel = ClientInterceptors.intercept(primaryChannel,
69+
new MirroringInterceptor(mirrorChannel, directExecutor));
70+
71+
// 3. Trigger call with Metadata
72+
Metadata headers = new Metadata();
73+
headers.put(testKey, "shadow-value");
74+
75+
ClientCall<String, String> call = interceptedChannel.newCall(method, CallOptions.DEFAULT);
76+
call.start(new ClientCall.Listener<String>() {}, headers);
77+
call.sendMessage("hello");
78+
call.halfClose();
79+
80+
// 4. Assertions
81+
assertTrue("Mirror server was not reached", mirrorLatch.await(1, TimeUnit.SECONDS));
82+
assertTrue("Headers were not correctly mirrored to shadow service", mirrorHeaderVerified.get());
83+
System.out.println("FULL MIRRORING SUCCESSFUL!");
84+
}
85+
}

0 commit comments

Comments
 (0)