bitflicker64 commented on code in PR #3130:
URL: https://github.com/apache/hugegraph/pull/3130#discussion_r3769711073
##########
hugegraph-store/hg-store-client/src/main/java/org/apache/hugegraph/store/client/grpc/AbstractGrpcClient.java:
##########
@@ -58,31 +114,21 @@ public AbstractGrpcClient() {
}
public ManagedChannel[] getChannels(String target) {
- ManagedChannel[] tc;
- if ((tc = channels.get(target)) == null) {
- synchronized (channels) {
- if ((tc = channels.get(target)) == null) {
- try {
- ManagedChannel[] value = new
ManagedChannel[concurrency];
- CountDownLatch latch = new CountDownLatch(concurrency);
- for (int i = 0; i < concurrency; i++) {
- int fi = i;
- executor.execute(() -> {
- try {
- value[fi] = createChannel(target);
- } catch (Exception e) {
- throw new RuntimeException(e);
- } finally {
- latch.countDown();
- }
- });
- }
- latch.await();
- channels.put(target, tc = value);
- } catch (Exception e) {
- throw new RuntimeException(e);
- }
- }
+ CompletableFuture<Void> refresh = this.triggerChannelRefresh(target);
+ ManagedChannel[] tc = channels.get(target);
+ if (tc != null) {
+ return tc;
+ }
+
+ /*
+ * Only the very first pool for a target waits, and only for a bounded
time: building it
+ * before its address is known makes the resolution that lands next
rebuild it. Waiting
+ * avoids that in the common case; if the wait expires the rebuild
still happens.
+ */
+ this.awaitInitialResolution(refresh);
+ synchronized (channels) {
+ if ((tc = channels.get(target)) == null) {
+ channels.put(target, tc = this.createChannels(target));
Review Comment:
Fixed in commit 16851352 and hardened in 9bacaad4. First-pool creation is
submitted to the dedicated prestarted channel-initialization executor, so a
cold Gremlin worker no longer creates channel workers on its restricted stack.
testColdTargetCreationStaysOffDeniedCaller starts with no pool, installs the
denying SecurityManager, invokes the public blocking-stub path, and verifies
the caller completes with a live stub while every channel is created
off-thread. The final focused suite is 29/29 with zero failures/errors/skips.
##########
hugegraph-store/hg-store-client/src/main/java/org/apache/hugegraph/store/client/grpc/AbstractGrpcClient.java:
##########
@@ -169,6 +232,316 @@ protected AbstractStub setStubOption(AbstractStub value) {
config.getGrpcMaxOutboundMessageSize());
}
+ private static boolean usesChannels(HgPair<ManagedChannel, ?>[] pairs,
+ ManagedChannel[] channels) {
+ if (pairs == null || pairs.length != channels.length) {
+ return false;
+ }
+ for (int i = 0; i < pairs.length; i++) {
+ HgPair<ManagedChannel, ?> pair = pairs[i];
+ if (pair == null || pair.getKey() != channels[i]) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Submits a refresh for the target unless one is already in flight or the
refresh interval
+ * has not elapsed. Returns the in-flight refresh, or null when none is
running.
+ */
+ private CompletableFuture<Void> triggerChannelRefresh(String target) {
+ CompletableFuture<Void> inFlight = refreshTasks.get(target);
+ if (inFlight != null) {
+ return inFlight;
+ }
+ if (!this.shouldRefreshChannels(target)) {
+ return null;
+ }
+
+ CompletableFuture<Void> refresh = new CompletableFuture<>();
+ CompletableFuture<Void> running = refreshTasks.putIfAbsent(target,
refresh);
+ if (running != null) {
+ return running;
+ }
+
+ // Throttle before submitting, so that a failing resolver cannot be
retried in a loop.
+ this.postponeNextRefresh(target);
+ try {
+ this.submitChannelRefresh(() -> {
+ try {
+ this.refreshChannelsIfAddressChanged(target);
+ } catch (Throwable e) {
+ // The executor discards what a task throws, so report it
here.
+ log.warn("Failed to refresh channels of target {}",
target, e);
+ } finally {
+ this.completeRefresh(target, refresh);
+ }
+ });
+ } catch (Throwable e) {
+ // Includes a thread creation denied on this thread; never leave
the entry behind.
+ log.warn("Failed to submit a channel refresh for target {}",
target, e);
+ this.completeRefresh(target, refresh);
+ }
+ return refresh;
+ }
+
+ private void completeRefresh(String target, CompletableFuture<Void>
refresh) {
+ /*
+ * Throttle from completion as well as from submission: a resolver
that is slow rather
+ * than failing can outlast its own interval, which would let every
later call queue
+ * another lookup behind it.
+ */
+ this.postponeNextRefresh(target);
+ refreshTasks.remove(target, refresh);
+ refresh.complete(null);
+ }
+
+ void submitChannelRefresh(Runnable task) {
+ CHANNEL_MAINTENANCE_EXECUTOR.execute(task);
+ }
+
+ private void awaitInitialResolution(CompletableFuture<Void> refresh) {
+ if (refresh == null) {
+ return;
+ }
+ try {
+ refresh.get(Math.max(0L, this.initialResolutionTimeoutNanos()),
+ TimeUnit.NANOSECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } catch (Exception ignored) {
+ // A slow or failing resolver must not delay the first pool any
further.
+ }
+ }
+
+ /**
+ * Runs on a maintenance thread, never on a request thread. At most one
runs per target at a
+ * time — that comes from the refreshTasks entry, not from the size of the
executor. Replaces
+ * the target's pool when its resolved address set has changed, publishing
the replacement
+ * before retiring the previous pool.
+ */
+ private void refreshChannelsIfAddressChanged(String target) {
+ String resolvedTarget = this.resolveTarget(target);
+ if (resolvedTarget.isEmpty()) {
+ return;
+ }
+
+ ManagedChannel[] staleChannels = channels.get(target);
+ String previousTarget = resolvedTargets.get(target);
+ if (resolvedTarget.equals(previousTarget)) {
+ return;
+ }
+ if (staleChannels == null) {
+ /*
+ * Nothing to replace yet. Recording the address here is what lets
the common path
+ * build its first pool already knowing the address, instead of
rebuilding it.
+ */
+ resolvedTargets.put(target, resolvedTarget);
+ return;
+ }
+
+ ManagedChannel[] replacementChannels;
+ try {
+ replacementChannels = this.createChannels(target);
+ } catch (RuntimeException e) {
+ // Keep serving from the last healthy pool.
+ log.warn("Failed to create replacement channels of target {}, " +
+ "keeping the current pool", target, e);
+ return;
+ }
+
+ ReentrantReadWriteLock.WriteLock writeLock =
channelLock(target).writeLock();
+ writeLock.lock();
+ try {
+ boolean replaced = false;
+ synchronized (channels) {
+ if (channels.get(target) == staleChannels) {
+ channels.put(target, replacementChannels);
+ resolvedTargets.put(target, resolvedTarget);
+ replaced = true;
+ }
+ }
+ if (replaced) {
+ log.info("Replaced the channel pool of target {}, address
changed from {} to {}",
+ target, previousTarget, resolvedTarget);
+ }
+ this.retireChannels(replaced ? staleChannels :
replacementChannels);
+ } finally {
+ writeLock.unlock();
+ }
+ }
+
+ private boolean shouldRefreshChannels(String target) {
+ AtomicReference<Long> nextResolution =
+ nextResolutions.computeIfAbsent(target, key -> new
AtomicReference<>());
+ Long deadline = nextResolution.get();
+ return deadline == null || this.nanoTime() - deadline >= 0L;
+ }
+
+ private void postponeNextRefresh(String target) {
+ long interval = Math.max(0L, this.channelRefreshIntervalNanos());
+ nextResolutions.computeIfAbsent(target, key -> new AtomicReference<>())
+ .set(this.nanoTime() + interval);
+ }
+
+ protected long nanoTime() {
+ return System.nanoTime();
+ }
+
+ protected long channelRefreshIntervalNanos() {
+ return DEFAULT_CHANNEL_REFRESH_INTERVAL_NANOS;
+ }
+
+ private long initialResolutionTimeoutNanos() {
+ return DEFAULT_INITIAL_RESOLUTION_TIMEOUT_NANOS;
+ }
+
+ protected long channelDrainTimeoutNanos() {
+ return TimeUnit.SECONDS.toNanos(config.getGrpcTimeoutSeconds());
+ }
+
+ private ManagedChannel[] createChannels(String target) {
+ ManagedChannel[] value = new ManagedChannel[concurrency];
+ CountDownLatch latch = new CountDownLatch(concurrency);
+ AtomicReference<RuntimeException> failure = new AtomicReference<>();
+ for (int i = 0; i < concurrency; i++) {
+ int fi = i;
+ try {
+ this.submitChannelCreation(() -> {
+ try {
+ value[fi] = createChannel(target);
+ } catch (Exception e) {
+ failure.compareAndSet(null, new RuntimeException(e));
+ } finally {
+ latch.countDown();
+ }
+ });
+ } catch (RuntimeException e) {
+ failure.compareAndSet(null, e);
+ latch.countDown();
+ }
+ }
+
+ InterruptedException interruption = null;
+ while (latch.getCount() > 0L) {
+ try {
+ latch.await();
+ } catch (InterruptedException e) {
+ interruption = e;
+ }
+ }
+
+ if (failure.get() != null || interruption != null) {
+ forceTerminateChannels(value);
+ }
+ if (interruption != null) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException(interruption);
+ }
+ if (failure.get() != null) {
+ throw failure.get();
+ }
+ return value;
+ }
+
+ void submitChannelCreation(Runnable task) {
+ this.executor.execute(task);
+ }
+
+ private void retireChannels(ManagedChannel[] retiredChannels) {
+ Arrays.stream(retiredChannels)
+ .filter(channel -> channel != null && !channel.isShutdown())
+ .forEach(ManagedChannel::shutdown);
+
+ long timeout = Math.max(0L, this.channelDrainTimeoutNanos());
+ try {
+ this.scheduleChannelRetirement(() ->
forceTerminateChannels(retiredChannels), timeout);
+ } catch (RuntimeException e) {
+ log.warn("Failed to schedule forced retirement, forcing channels
immediately", e);
+ forceTerminateChannels(retiredChannels);
+ }
+ }
+
+ void scheduleChannelRetirement(Runnable task, long timeoutNanos) {
+ CHANNEL_RETIREMENT_EXECUTOR.schedule(task, timeoutNanos,
TimeUnit.NANOSECONDS);
+ }
+
+ private void forceTerminateChannels(ManagedChannel[] retiredChannels) {
+ for (ManagedChannel channel : retiredChannels) {
+ if (channel != null && !channel.isTerminated()) {
+ channel.shutdownNow();
+ }
+ }
+ }
+
+ /**
+ * Extracts the host that a gRPC target resolves through, covering the
plain {@code host:port}
+ * form and the {@code dns:} scheme in both its {@code dns:host:port} and
+ * {@code dns://authority/host:port} spellings. Any other resolver scheme
returns an empty
+ * host, leaving that target to gRPC instead of monitoring the wrong
endpoint.
+ */
+ private static String targetHost(String target) {
+ if (target == null || target.isEmpty()) {
+ return "";
+ }
+
+ String endpoint = target;
+ if (target.regionMatches(true, 0, DNS_SCHEME, 0, DNS_SCHEME.length()))
{
Review Comment:
Fixed in 9bacaad4. targetHost now accepts only lowercase dns targets with a
slash-prefixed URI path, matching gRPC 1.39; raw host:port and bracketed IPv6
remain supported, while scheme-only dns:host:port and uppercase DNS forms are
skipped. The target parser test covers the accepted and rejected forms.
##########
hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/client/grpc/AbstractGrpcClientTest.java:
##########
@@ -40,70 +57,1039 @@
import io.grpc.MethodDescriptor;
import io.grpc.stub.AbstractAsyncStub;
import io.grpc.stub.AbstractBlockingStub;
+import io.grpc.stub.AbstractStub;
/**
- * Verifies that the stub pools of {@link AbstractGrpcClient} spread their
entries over every
- * channel created for a target, instead of binding all of them to a single
channel.
+ * Verifies that Store address changes replace channels and cached stubs
safely, and that the
+ * refresh never resolves or creates channels on the thread that asked for a
stub.
*/
public class AbstractGrpcClientTest {
+ private static final String MAINTENANCE_THREAD_PREFIX =
"channel-maintenance";
+ private static final String RETIREMENT_THREAD_PREFIX =
"channel-retirement";
private static final AtomicInteger TARGET_SEQ = new AtomicInteger();
private static String uniqueTarget(String prefix) {
return prefix + "-" + TARGET_SEQ.incrementAndGet() + ":8500";
}
- private static Set<ManagedChannel> identitySet(Collection<ManagedChannel>
channels) {
- Set<ManagedChannel> set = Collections.newSetFromMap(new
IdentityHashMap<>());
- set.addAll(channels);
- return set;
+ private static boolean belongsToPool(Channel channel, ManagedChannel[]
channels) {
+ return Arrays.stream(channels).anyMatch(current -> current == channel);
+ }
+
+ private static boolean allChannelsAreShutdown(ManagedChannel[] channels) {
+ return Arrays.stream(channels).allMatch(ManagedChannel::isShutdown);
+ }
+
+ private static boolean allChannelsAreLive(ManagedChannel[] channels) {
+ return Arrays.stream(channels).noneMatch(ManagedChannel::isShutdown);
+ }
+
+ private static List<FakeManagedChannel> fakeChannels(ManagedChannel[]
channels) {
+ return Arrays.stream(channels)
+ .map(channel -> (FakeManagedChannel) channel)
+ .collect(Collectors.toList());
+ }
+
+ private static void assertUsesEveryChannel(String message,
+ List<ManagedChannel>
stubChannels,
+ ManagedChannel[] channels) {
+ assertEquals(message, channels.length, new
HashSet<>(stubChannels).size());
+ }
+
+ private static void assertCachedChannelsCurrentAndLive(String message,
+
List<ManagedChannel> cached,
+ ManagedChannel[]
current) {
+ assertEquals(message, current.length, cached.size());
+ assertTrue(message, cached.stream().allMatch(channel ->
+ belongsToPool(channel, current) && !channel.isShutdown()));
+ }
+
+ private static void awaitCondition(String message, Condition condition)
throws Exception {
+ awaitCondition(message, 5L, TimeUnit.SECONDS, condition);
+ }
+
+ private static void awaitCondition(String message, long timeout, TimeUnit
unit,
+ Condition condition) throws Exception {
+ long start = System.nanoTime();
+ long timeoutNanos = unit.toNanos(timeout);
+ while (System.nanoTime() - start < timeoutNanos) {
+ if (condition.isTrue()) {
+ return;
+ }
+ Thread.sleep(10L);
+ }
+ assertTrue(message, condition.isTrue());
+ }
+
+ /**
+ * Refresh runs on the maintenance thread, so a replacement pool becomes
visible some time
+ * after the address changes rather than on the call that observes the
change. Retirement
+ * deliberately trails publication, so waiting for both is what marks a
refresh complete.
+ */
+ private static ManagedChannel[] awaitPoolReplacement(RecordingGrpcClient
client,
+ String target,
+ ManagedChannel[]
staleChannels)
+ throws Exception {
+ awaitCondition("refresh must publish a replacement pool",
+ () -> client.getChannels(target) != staleChannels);
+ awaitCondition("refresh must retire the previous pool after
publishing",
+ () -> allChannelsAreShutdown(staleChannels));
+ return client.getChannels(target);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static boolean refreshIsIdle(AbstractGrpcClient client, String
target)
+ throws Exception {
+ Field field =
AbstractGrpcClient.class.getDeclaredField("refreshTasks");
+ field.setAccessible(true);
+ return !((Map<String, ?>) field.get(client)).containsKey(target);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static List<ManagedChannel>
cachedAsyncStubChannels(AbstractGrpcClient client,
+ String target)
+ throws Exception {
+ Field field = AbstractGrpcClient.class.getDeclaredField("asyncStubs");
+ field.setAccessible(true);
+ Map<String, HgPair<ManagedChannel, AbstractAsyncStub>[]> stubs =
+ (Map<String, HgPair<ManagedChannel, AbstractAsyncStub>[]>)
field.get(client);
+ HgPair<ManagedChannel, AbstractAsyncStub>[] pairs = stubs.get(target);
+ assertNotNull("the async stub cache must exist", pairs);
+ return
Arrays.stream(pairs).map(HgPair::getKey).collect(Collectors.toList());
+ }
+
+ private static ThreadPoolExecutor
channelCreationExecutor(AbstractGrpcClient client)
+ throws Exception {
+ Field field = AbstractGrpcClient.class.getDeclaredField("executor");
+ field.setAccessible(true);
+ return (ThreadPoolExecutor) field.get(client);
}
@Test
- public void testBlockingStubPoolCoversEveryChannel() {
- String target = uniqueTarget("blocking");
+ public void testAddressChangeReplacesChannelAndStubPools() throws
Exception {
+ String target = uniqueTarget("address-change");
RecordingGrpcClient client = new RecordingGrpcClient();
- ManagedChannel[] channels = client.getChannels(target);
- assertTrue("pool must hold more than one channel", channels.length >
1);
+ ManagedChannel[] oldChannels = client.getChannels(target);
+ assertNotNull(client.getBlockingStub(target));
+ assertNotNull(client.getAsyncStub(target));
+
+ client.resolvedTarget = "10.0.0.2";
+ ManagedChannel[] newChannels = awaitPoolReplacement(client, target,
oldChannels);
+ assertTrue("every stale channel must be gracefully shut down",
+ allChannelsAreShutdown(oldChannels));
+ assertFalse("refresh must not force close stale channels immediately",
+ fakeChannels(oldChannels).stream()
+
.anyMatch(FakeManagedChannel::isForceShutdown));
- // Pool initialisation: one stub per channel, each bound to a
different channel.
+ client.blockingStubChannels.clear();
+ client.asyncStubChannels.clear();
assertNotNull(client.getBlockingStub(target));
- assertEquals("one stub per channel", channels.length,
client.blockingStubChannels.size());
- Set<ManagedChannel> bound = identitySet(client.blockingStubChannels);
- assertEquals("stubs must not share a channel", channels.length,
bound.size());
- assertTrue("stubs must cover the channels of the target",
- bound.containsAll(Arrays.asList(channels)));
+ assertNotNull(client.getAsyncStub(target));
+ assertCachedChannelsCurrentAndLive("the blocking stub pool must be
rebuilt on the new pool",
+ client.blockingStubChannels,
newChannels);
+ assertUsesEveryChannel("blocking stubs must be spread across the pool",
+ client.blockingStubChannels, newChannels);
+ assertCachedChannelsCurrentAndLive("the async stub pool must be
rebuilt on the new pool",
+ client.asyncStubChannels,
newChannels);
+ assertUsesEveryChannel("async stubs must be spread across the pool",
+ client.asyncStubChannels, newChannels);
}
@Test
- public void testAsyncStubPoolCoversEveryChannel() {
- String target = uniqueTarget("async");
+ public void testStubPoolsCoverEveryChannelOnFirstBuild() {
+ String target = uniqueTarget("initial-stub-spread");
RecordingGrpcClient client = new RecordingGrpcClient();
ManagedChannel[] channels = client.getChannels(target);
- assertTrue("pool must hold more than one channel", channels.length >
1);
+ assertNotNull(client.getBlockingStub(target));
+ assertNotNull(client.getAsyncStub(target));
+ assertUsesEveryChannel("the first blocking stub pool must cover every
channel",
+ client.blockingStubChannels, channels);
+ assertUsesEveryChannel("the first async stub pool must cover every
channel",
+ client.asyncStubChannels, channels);
+ }
+
+ @Test
+ public void testFirstPoolIsBuiltAfterItsAddressIsKnown() throws Exception {
+ String target = uniqueTarget("initial-resolution");
+ RecordingGrpcClient client = new RecordingGrpcClient();
+ ManagedChannel[] initialChannels = client.getChannels(target);
+
+ assertEquals("the first pool must be built once its address is known",
+ 1, client.resolutionCount.get());
+ // Let every refresh settle first, otherwise the assertions below race
the swap.
+ awaitCondition("the refresh must settle", () -> refreshIsIdle(client,
target));
+ assertSame("a pool built with a known address must not be replaced",
+ initialChannels, client.getChannels(target));
+ awaitCondition("the settled refresh must leave no further work",
+ () -> refreshIsIdle(client, target));
+ assertTrue("the first pool must not be retired by its own resolution",
+ allChannelsAreLive(initialChannels));
+ }
+
+ @Test
+ public void testInitialRefreshRunsWhenNanoTimeIsNegative() throws
Exception {
+ String target = uniqueTarget("negative-nano-time");
+ RecordingGrpcClient client = new RecordingGrpcClient();
+ client.currentNanoTime = -1L;
+ client.refreshIntervalNanos = 5L;
+
+ ManagedChannel[] initialChannels = client.getChannels(target);
+ awaitCondition("the initial refresh must settle", () ->
refreshIsIdle(client, target));
+
+ assertEquals("a negative clock value must not suppress the initial
refresh",
+ 1, client.resolutionCount.get());
+ assertTrue("the initial pool must remain healthy",
allChannelsAreLive(initialChannels));
+ }
+
+ @Test
+ public void testRefreshDeadlineSurvivesNanoTimeWraparound() throws
Exception {
+ String target = uniqueTarget("wrapped-nano-time");
+ RecordingGrpcClient client = new RecordingGrpcClient();
+ client.currentNanoTime = Long.MAX_VALUE - 2L;
+ client.refreshIntervalNanos = 5L;
+
+ client.getChannels(target);
+ awaitCondition("the initial refresh must settle", () ->
refreshIsIdle(client, target));
+ assertEquals(1, client.resolutionCount.get());
+
+ client.currentNanoTime = Long.MAX_VALUE - 1L;
+ client.getChannels(target);
+ assertEquals("the wrapped deadline must not fire early", 1,
+ client.resolutionCount.get());
+
+ client.currentNanoTime = Long.MIN_VALUE + 2L;
+ client.getChannels(target);
+ awaitCondition("the wrapped deadline must eventually fire",
+ () -> client.resolutionCount.get() == 2 &&
+ refreshIsIdle(client, target));
+ }
+
+ @Test
+ public void testUnknownAddressPoolIsReplacedOnFirstSuccessfulResolution()
throws Exception {
+ String target = uniqueTarget("first-successful-resolution");
+ RecordingGrpcClient client = new RecordingGrpcClient();
+ client.resolvedTarget = "";
+ ManagedChannel[] unknownChannels = client.getChannels(target);
+
+ client.resolvedTarget = "10.0.0.1";
+ ManagedChannel[] resolvedChannels = awaitPoolReplacement(client,
target, unknownChannels);
+ assertTrue("every channel from the unknown pool must be gracefully
shut down",
+ allChannelsAreShutdown(unknownChannels));
+ assertFalse("unknown channels must not be force closed immediately",
+ fakeChannels(unknownChannels).stream()
+
.anyMatch(FakeManagedChannel::isForceShutdown));
+ assertTrue("the resolved channel pool must remain live",
+ allChannelsAreLive(resolvedChannels));
+ }
+
+ /**
+ * HugeSecurityManager denies socket connection and thread creation on
Gremlin worker stacks,
+ * and InetAddress.getAllByName() performs exactly the checkConnect(host,
-1) simulated here.
+ * A refresh triggered by such a caller must therefore resolve somewhere
else entirely.
+ */
+ @Test
+ public void testRefreshSucceedsWhenCallerIsDeniedSocketAndThreadAccess()
throws Exception {
+ String target = uniqueTarget("denied-caller");
+ HostCapturingGrpcClient client = new HostCapturingGrpcClient();
+ client.checkSocketPermission = true;
+ client.activeCallsFinished = new CountDownLatch(1);
+ client.drainTimeoutNanos = 0L;
+ ManagedChannel[] oldChannels = client.getChannels(target);
+ assertNotNull(client.getBlockingStub(target));
+ awaitCondition("the initial refresh must settle before installing the
security manager",
+ () -> refreshIsIdle(client, target));
+ client.resolutionThreads.clear();
+ client.creationThreads.clear();
+ client.retirementThreads.clear();
+
+ SecurityManager previous = System.getSecurityManager();
+ System.setSecurityManager(new DenyingWorkerSecurityManager());
+ AtomicReference<Throwable> failure = new AtomicReference<>();
+ AtomicReference<AbstractBlockingStub> stub = new AtomicReference<>();
+ try {
+ client.resolvedAddress = "10.0.0.2";
+ // The name is what HugeSecurityManager keys its Gremlin worker
check on.
+ Thread worker = new Thread(() -> {
+ try {
+ stub.set(client.getBlockingStub(target));
+ } catch (Throwable e) {
+ failure.set(e);
+ }
+ }, "gremlin-server-exec-1");
+ worker.start();
+ worker.join(TimeUnit.SECONDS.toMillis(10L));
+
+ assertFalse("the denied caller must finish", worker.isAlive());
+ assertNotNull("a denied caller must still receive a stub",
stub.get());
+ assertTrue("a denied caller must not observe a security failure: "
+ failure.get(),
+ failure.get() == null);
+ awaitCondition("the refresh must still publish a replacement pool",
+ () -> AbstractGrpcClient.channels.get(target) !=
oldChannels);
+ awaitCondition("forced retirement must finish on the maintenance
executor",
+ () -> fakeChannels(oldChannels).stream()
+
.allMatch(FakeManagedChannel::isTerminated));
+ assertFalse("the assertion below is vacuous unless something
resolved",
+ client.resolutionThreads.isEmpty());
+ assertTrue("every resolution must run on the channel maintenance
thread",
+ client.resolutionThreads.stream()
+ .allMatch(name ->
name.startsWith(
+
MAINTENANCE_THREAD_PREFIX)));
+ assertEquals("the replacement must include every channel",
+ AbstractGrpcClient.concurrency,
client.creationThreads.size());
+ assertTrue("replacement construction must stay off the denied
caller",
+ client.creationThreads.stream().noneMatch(
+ name ->
name.startsWith("gremlin-server-exec")));
+ assertFalse("the retirement assertion must observe lifecycle work",
+ client.retirementThreads.isEmpty());
+ assertTrue("graceful retirement must run on the maintenance
thread",
+ client.retirementThreads.stream().anyMatch(
+ name ->
name.startsWith(MAINTENANCE_THREAD_PREFIX)));
+ assertTrue("forced retirement must run on the retirement thread",
+ client.retirementThreads.stream().anyMatch(
+ name ->
name.startsWith(RETIREMENT_THREAD_PREFIX)));
+ assertTrue("retirement must stay off the denied caller",
+ client.retirementThreads.stream().noneMatch(
+ name ->
name.startsWith("gremlin-server-exec")));
+ } finally {
+ client.activeCallsFinished.countDown();
+ System.setSecurityManager(previous);
+ }
+ }
+
+ @Test
+ public void testResolutionFailureKeepsHealthyPoolAndAllowsRetry() throws
Exception {
+ String target = uniqueTarget("resolution-failure");
+ RecordingGrpcClient client = new RecordingGrpcClient();
+ ManagedChannel[] healthyChannels = client.getChannels(target);
+ awaitCondition("the initial refresh must settle", () ->
refreshIsIdle(client, target));
+
+ client.resolutionFailure = new IllegalStateException("injected
resolution failure");
+ client.getChannels(target);
+ awaitCondition("the failed refresh must clear its single-flight entry",
+ () -> refreshIsIdle(client, target));
+ assertSame("a resolution failure must preserve the published pool",
healthyChannels,
+ AbstractGrpcClient.channels.get(target));
+ assertTrue("a resolution failure must leave the published pool live",
+ allChannelsAreLive(healthyChannels));
+
+ client.resolutionFailure = null;
+ client.resolvedTarget = "10.0.0.2";
+ ManagedChannel[] replacement = awaitPoolReplacement(client, target,
healthyChannels);
+ assertTrue("a later successful refresh must replace the pool",
+ allChannelsAreLive(replacement));
+ }
+
+ @Test
+ public void testRejectedRefreshSubmissionKeepsHealthyPoolAndAllowsRetry()
throws Exception {
+ String target = uniqueTarget("refresh-submission-failure");
+ RecordingGrpcClient client = new RecordingGrpcClient();
+ ManagedChannel[] healthyChannels = client.getChannels(target);
+ awaitCondition("the initial refresh must settle", () ->
refreshIsIdle(client, target));
+
+ client.rejectNextRefreshSubmission.set(true);
+ client.resolvedTarget = "10.0.0.2";
+ client.getChannels(target);
+ assertTrue("a rejected refresh must clear its single-flight entry",
+ refreshIsIdle(client, target));
+ assertSame("a rejected refresh must preserve the published pool",
healthyChannels,
+ AbstractGrpcClient.channels.get(target));
+ assertTrue("a rejected refresh must leave the published pool live",
+ allChannelsAreLive(healthyChannels));
+
+ ManagedChannel[] replacement = awaitPoolReplacement(client, target,
healthyChannels);
+ assertTrue("a later refresh submission must still succeed",
+ allChannelsAreLive(replacement));
+ }
+
+ @Test
+ public void testReplacementCreationFailureKeepsHealthyPoolAndAllowsRetry()
throws Exception {
+ String target = uniqueTarget("replacement-creation-failure");
+ CreationControlGrpcClient client = new CreationControlGrpcClient();
+ ManagedChannel[] healthyChannels = client.getChannels(target);
+ awaitCondition("the initial refresh must settle", () ->
refreshIsIdle(client, target));
+ int createdBeforeFailure = client.createdChannels.size();
+
+ client.failedAttempt = client.attempt.get() + 5;
+ client.resolvedTarget = "10.0.0.2";
+ client.getChannels(target);
+ awaitCondition("the failed replacement must clear its single-flight
entry",
+ () -> refreshIsIdle(client, target));
+
+ assertSame("a replacement failure must preserve the published pool",
healthyChannels,
+ AbstractGrpcClient.channels.get(target));
+ assertTrue("a replacement failure must leave the published pool live",
+ allChannelsAreLive(healthyChannels));
+ List<ManagedChannel> partialChannels =
+ client.createdChannels.subList(createdBeforeFailure,
+ client.createdChannels.size());
+ assertEquals("all successful replacement tasks must converge before
failure returns",
+ AbstractGrpcClient.concurrency - 1,
partialChannels.size());
+ assertTrue("every partial replacement channel must be force
terminated",
+ partialChannels.stream().allMatch(channel ->
+ channel.isTerminated() &&
+ ((FakeManagedChannel) channel).isForceShutdown()));
+
+ client.failedAttempt = -1;
+ ManagedChannel[] replacement = awaitPoolReplacement(client, target,
healthyChannels);
+ assertTrue("a later replacement attempt must still succeed",
+ allChannelsAreLive(replacement));
+ }
+
+ @Test
+ public void testRejectedChannelCreationSubmissionRetiresPartialPool()
throws Exception {
+ String target = uniqueTarget("channel-submission-failure");
+ RecordingGrpcClient client = new RecordingGrpcClient();
+ ManagedChannel[] healthyChannels = client.getChannels(target);
+ awaitCondition("the initial refresh must settle", () ->
refreshIsIdle(client, target));
+ int createdBeforeFailure = client.createdChannels.size();
+
+ client.rejectNextChannelSubmission.set(true);
+ client.resolvedTarget = "10.0.0.2";
+ client.getChannels(target);
+ awaitCondition("the failed replacement must clear its single-flight
entry",
+ () -> refreshIsIdle(client, target));
+
+ assertSame("a task-submission failure must preserve the published
pool", healthyChannels,
+ AbstractGrpcClient.channels.get(target));
+ List<ManagedChannel> partialChannels =
+ client.createdChannels.subList(createdBeforeFailure,
+ client.createdChannels.size());
+ assertEquals("one rejected task must not prevent the others from
converging",
+ AbstractGrpcClient.concurrency - 1,
partialChannels.size());
+ assertTrue("partial channels must be retired after a task-submission
failure",
+ partialChannels.stream().allMatch(channel ->
+ channel.isTerminated() &&
+ ((FakeManagedChannel) channel).isForceShutdown()));
+
+ ManagedChannel[] replacement = awaitPoolReplacement(client, target,
healthyChannels);
+ assertTrue("a later replacement attempt must still succeed",
+ allChannelsAreLive(replacement));
+ }
+
+ @Test
+ public void
testRejectedRetirementSchedulingKeepsReplacementAndAllowsRetry()
+ throws Exception {
+ String target = uniqueTarget("retirement-submission-failure");
+ RecordingGrpcClient client = new RecordingGrpcClient();
+ client.activeCallsFinished = new CountDownLatch(1);
+ ManagedChannel[] firstChannels = client.getChannels(target);
+ awaitCondition("the initial refresh must settle", () ->
refreshIsIdle(client, target));
+
+ try {
+ client.rejectNextRetirementSubmission.set(true);
+ client.resolvedTarget = "10.0.0.2";
+ client.getChannels(target);
+ awaitCondition("the replacement must be published despite cleanup
rejection",
+ () -> AbstractGrpcClient.channels.get(target) !=
firstChannels);
+ awaitCondition("the cleanup rejection must clear its single-flight
entry",
+ () -> refreshIsIdle(client, target));
+ ManagedChannel[] secondChannels =
AbstractGrpcClient.channels.get(target);
+ assertTrue("the replacement must remain live after cleanup
rejection",
+ allChannelsAreLive(secondChannels));
+ assertTrue("the unscheduled pool must be force terminated
immediately",
+ fakeChannels(firstChannels).stream().allMatch(channel ->
+ channel.isTerminated() &&
channel.isForceShutdown()));
+
+ client.resolvedTarget = "10.0.0.3";
+ ManagedChannel[] thirdChannels = awaitPoolReplacement(client,
target, secondChannels);
+ assertTrue("a later refresh must still replace the pool",
+ allChannelsAreLive(thirdChannels));
+ } finally {
+ client.activeCallsFinished.countDown();
+ }
+ }
+
+ @Test
+ public void testRetirementDoesNotBlockWhenCreationExecutorIsSaturated()
throws Exception {
+ String target = uniqueTarget("saturated-retirement");
+ RecordingGrpcClient client = new RecordingGrpcClient();
+ client.activeCallsFinished = new CountDownLatch(1);
+ client.drainTimeoutNanos = TimeUnit.SECONDS.toNanos(5L);
+ ManagedChannel[] oldChannels = client.getChannels(target);
+ ThreadPoolExecutor channelExecutor = channelCreationExecutor(client);
+ CountDownLatch workersStarted = new
CountDownLatch(AbstractGrpcClient.concurrency);
+ CountDownLatch releaseWorkers = new CountDownLatch(1);
+
+ try {
+ for (int i = 0; i < AbstractGrpcClient.concurrency; i++) {
+ channelExecutor.execute(() -> {
+ workersStarted.countDown();
+ try {
+ releaseWorkers.await();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ });
+ }
+ assertTrue("the shared creation executor must be fully occupied",
+ workersStarted.await(5, TimeUnit.SECONDS));
+
+ client.resolvedTarget = "10.0.0.2";
+ ManagedChannel[] newChannels = awaitPoolReplacement(client,
target, oldChannels);
+
+ assertNotSame("refresh must publish the replacement pool",
oldChannels, newChannels);
+ assertTrue("the retired pool must be shut down once refresh
completes",
+ allChannelsAreShutdown(oldChannels));
+ assertFalse("the scheduled cleanup must preserve the drain window",
+ fakeChannels(oldChannels).stream()
+
.anyMatch(FakeManagedChannel::isForceShutdown));
+ } finally {
+ releaseWorkers.countDown();
+ client.activeCallsFinished.countDown();
+ }
+ }
+
+ @Test
+ public void testRetirementDeadlineSurvivesBlockedRefreshWorkers() throws
Exception {
+ String retiringTarget = uniqueTarget("isolated-retirement");
+ String firstBlockedTarget = uniqueTarget("blocked-refresh");
+ String secondBlockedTarget = uniqueTarget("blocked-refresh");
+ RecordingGrpcClient retiringClient = new RecordingGrpcClient();
+ RecordingGrpcClient firstBlockedClient = new RecordingGrpcClient();
+ RecordingGrpcClient secondBlockedClient = new RecordingGrpcClient();
+ ManagedChannel[] firstBlockedChannels =
firstBlockedClient.getChannels(firstBlockedTarget);
+ ManagedChannel[] secondBlockedChannels =
+ secondBlockedClient.getChannels(secondBlockedTarget);
+ awaitCondition("the first blocking client must initialize",
+ () -> refreshIsIdle(firstBlockedClient,
firstBlockedTarget));
+ awaitCondition("the second blocking client must initialize",
+ () -> refreshIsIdle(secondBlockedClient,
secondBlockedTarget));
+
+ retiringClient.activeCallsFinished = new CountDownLatch(1);
+ retiringClient.drainTimeoutNanos = TimeUnit.SECONDS.toNanos(2L);
+ ManagedChannel[] retiredChannels =
retiringClient.getChannels(retiringTarget);
+ awaitCondition("the retiring client must initialize",
+ () -> refreshIsIdle(retiringClient, retiringTarget));
+ retiringClient.resolvedTarget = "10.0.0.2";
+ ManagedChannel[] replacement =
+ awaitPoolReplacement(retiringClient, retiringTarget,
retiredChannels);
+ awaitCondition("the replacement refresh must settle",
+ () -> refreshIsIdle(retiringClient, retiringTarget));
+
+ firstBlockedClient.delayResolution = true;
+ firstBlockedClient.delayedResolutionTimeoutSeconds = 30L;
+ firstBlockedClient.resolvedTarget = "10.0.0.2";
+ secondBlockedClient.delayResolution = true;
+ secondBlockedClient.delayedResolutionTimeoutSeconds = 30L;
+ secondBlockedClient.resolvedTarget = "10.0.0.2";
+ try {
+ assertSame(firstBlockedChannels,
firstBlockedClient.getChannels(firstBlockedTarget));
+ assertTrue("the first maintenance worker must block in resolution",
+ firstBlockedClient.delayedResolutionStarted.await(5,
TimeUnit.SECONDS));
+ assertSame(secondBlockedChannels,
secondBlockedClient.getChannels(secondBlockedTarget));
+ assertTrue("the second maintenance worker must block in
resolution",
+ secondBlockedClient.delayedResolutionStarted.await(5,
TimeUnit.SECONDS));
+ assertFalse("the drain deadline must still be pending after
workers are blocked",
+ fakeChannels(retiredChannels).stream()
+
.anyMatch(FakeManagedChannel::isForceShutdown));
+
+ awaitCondition("blocked refresh workers must not delay forced
retirement",
+ 4L, TimeUnit.SECONDS,
+ () ->
fakeChannels(retiredChannels).stream().allMatch(channel ->
+ channel.isTerminated() &&
channel.isForceShutdown()));
+ assertTrue("the replacement pool must remain live",
allChannelsAreLive(replacement));
+ } finally {
+ firstBlockedClient.releaseDelayedResolution.countDown();
+ secondBlockedClient.releaseDelayedResolution.countDown();
+ retiringClient.activeCallsFinished.countDown();
+ awaitCondition("the first blocked refresh must settle",
+ () -> refreshIsIdle(firstBlockedClient,
firstBlockedTarget));
+ awaitCondition("the second blocked refresh must settle",
+ () -> refreshIsIdle(secondBlockedClient,
secondBlockedTarget));
+ }
+ }
+
+ @Test
+ public void testBlockedResolutionsDoNotStarveAnotherTarget() throws
Exception {
+ String firstTarget = uniqueTarget("isolated-refresh");
+ String secondTarget = uniqueTarget("isolated-refresh");
+ String thirdTarget = uniqueTarget("isolated-refresh");
+ RecordingGrpcClient first = new RecordingGrpcClient();
+ RecordingGrpcClient second = new RecordingGrpcClient();
+ RecordingGrpcClient third = new RecordingGrpcClient();
+ ManagedChannel[] firstChannels = first.getChannels(firstTarget);
+ ManagedChannel[] secondChannels = second.getChannels(secondTarget);
+ ManagedChannel[] thirdChannels = third.getChannels(thirdTarget);
+ awaitCondition("initial refreshes must settle", () ->
refreshIsIdle(first, firstTarget) &&
+
refreshIsIdle(second, secondTarget) &&
+
refreshIsIdle(third, thirdTarget));
+
+ first.delayResolution = true;
+ second.delayResolution = true;
+ first.delayedResolutionTimeoutSeconds = 30L;
+ second.delayedResolutionTimeoutSeconds = 30L;
+ first.resolvedTarget = "10.0.0.2";
+ second.resolvedTarget = "10.0.0.2";
+ third.resolvedTarget = "10.0.0.2";
+ try {
+ assertSame(firstChannels, first.getChannels(firstTarget));
+ assertTrue(first.delayedResolutionStarted.await(5,
TimeUnit.SECONDS));
+ assertSame(secondChannels, second.getChannels(secondTarget));
+ assertTrue(second.delayedResolutionStarted.await(5,
TimeUnit.SECONDS));
+
+ ManagedChannel[] replacement = awaitPoolReplacement(third,
thirdTarget, thirdChannels);
+ assertNotSame("the third target must refresh while two lookups are
blocked",
+ thirdChannels, replacement);
+ assertTrue("the third target replacement must remain live",
+ allChannelsAreLive(replacement));
+ } finally {
+ first.releaseDelayedResolution.countDown();
+ second.releaseDelayedResolution.countDown();
+ awaitCondition("blocked refreshes must settle",
+ () -> refreshIsIdle(first, firstTarget) &&
+ refreshIsIdle(second, secondTarget));
+ }
+ }
+
+ @Test
+ public void testPartialChannelsAreRetiredAfterMixedCreationFailure() {
+ String target = uniqueTarget("partial-creation-failure");
+ CreationControlGrpcClient client = new CreationControlGrpcClient();
+ client.failedAttempt = 5;
+
+ try {
+ client.getChannels(target);
+ fail("channel creation must propagate the injected failure");
+ } catch (RuntimeException ignored) {
+ // Expected.
+ }
+
+ assertEquals("all creation tasks must converge before failure is
returned",
+ AbstractGrpcClient.concurrency - 1,
client.createdChannels.size());
+ assertTrue("every partial channel must be force terminated before
failure returns",
+ client.createdChannels.stream().allMatch(channel ->
+ channel.isTerminated() &&
+ ((FakeManagedChannel) channel).isForceShutdown()));
+ }
+
+ @Test
+ public void testInterruptedCreationWaitsAndRetiresPartialChannels() throws
Exception {
+ String target = uniqueTarget("interrupted-creation");
+ CreationControlGrpcClient client = new CreationControlGrpcClient();
+ client.releaseCreation = new CountDownLatch(1);
+ AtomicReference<Throwable> failure = new AtomicReference<>();
+ AtomicBoolean interrupted = new AtomicBoolean();
+ Thread caller = new Thread(() -> {
+ try {
+ client.getChannels(target);
+ } catch (Throwable e) {
+ failure.set(e);
+ interrupted.set(Thread.currentThread().isInterrupted());
+ }
+ });
+
+ caller.start();
+ assertTrue("all channel creation tasks must start",
+ client.creationStarted.await(5, TimeUnit.SECONDS));
+ caller.interrupt();
+ try {
+ Thread.sleep(100L);
+ assertTrue("an interrupted caller must wait for creation tasks to
converge",
+ caller.isAlive());
+ } finally {
+ client.releaseCreation.countDown();
+ }
+ caller.join(TimeUnit.SECONDS.toMillis(5L));
+
+ assertFalse("the interrupted creation call must finish",
caller.isAlive());
+ assertTrue("interruption must be reported as a runtime failure",
+ failure.get() instanceof RuntimeException);
+ assertTrue("the caller interrupt status must be restored",
interrupted.get());
+ assertEquals("every creation task must finish before interruption is
reported",
+ AbstractGrpcClient.concurrency,
client.createdChannels.size());
+ assertTrue("all partial channels must be force terminated before
interruption returns",
+ client.createdChannels.stream().allMatch(channel ->
+ channel.isTerminated() &&
+ ((FakeManagedChannel) channel).isForceShutdown()));
+ }
+
+ @Test
+ public void testDrainDeadlineForceTerminatesRetiredChannels() throws
Exception {
+ String target = uniqueTarget("drain-deadline");
+ RecordingGrpcClient client = new RecordingGrpcClient();
+ client.activeCallsFinished = new CountDownLatch(1);
+ client.drainTimeoutNanos = 0L;
+ ManagedChannel[] oldChannels = client.getChannels(target);
+
+ client.resolvedTarget = "10.0.0.2";
+ ManagedChannel[] newChannels = awaitPoolReplacement(client, target,
oldChannels);
+
+ awaitCondition("expired drain deadline must force terminate the
retired pool",
+ () ->
fakeChannels(oldChannels).stream().allMatch(channel ->
+ channel.isTerminated() &&
channel.isForceShutdown()));
+ assertTrue("the replacement pool must remain live",
allChannelsAreLive(newChannels));
+ }
+
+ @Test
+ public void testStubAcquisitionReusesResolutionWithinRefreshInterval()
throws Exception {
+ String target = uniqueTarget("throttled-refresh");
+ RecordingGrpcClient client = new RecordingGrpcClient();
+ client.refreshIntervalNanos = TimeUnit.HOURS.toNanos(1L);
+
+ assertNotNull(client.getBlockingStub(target));
assertNotNull(client.getAsyncStub(target));
- assertEquals("one stub per channel", channels.length,
client.asyncStubChannels.size());
- Set<ManagedChannel> bound = identitySet(client.asyncStubChannels);
- assertEquals("stubs must not share a channel", channels.length,
bound.size());
- assertTrue("stubs must cover the channels of the target",
- bound.containsAll(Arrays.asList(channels)));
+ for (int i = 0; i < 10; i++) {
+ assertNotNull(client.getBlockingStub(target));
+ assertNotNull(client.getAsyncStub(target));
+ }
+
+ Thread.sleep(100L);
+ assertEquals("stub acquisition must not resolve again inside the
refresh interval",
+ 1, client.resolutionCount.get());
+ }
+
+ @Test
+ public void
testConcurrentStubAcquisitionRetainsHealthyPoolDuringDelayedRefresh()
+ throws Exception {
+ String target = uniqueTarget("delayed-refresh");
+ RecordingGrpcClient client = new RecordingGrpcClient();
+ ManagedChannel[] oldChannels = client.getChannels(target);
+ assertNotNull(client.getBlockingStub(target));
+ // Let any refresh already in flight settle, so the resolution count
below is stable.
+ awaitCondition("the initial refresh must settle before the count is
captured",
+ () -> refreshIsIdle(client, target));
+ int resolutionsBeforeConcurrentCalls = client.resolutionCount.get();
+
+ client.resolvedTarget = "10.0.0.2";
+ client.delayResolution = true;
+ client.refreshIntervalNanos = TimeUnit.HOURS.toNanos(1L);
+
+ ExecutorService executor = Executors.newFixedThreadPool(6);
+ List<Future<AbstractBlockingStub>> futures = new ArrayList<>();
+ try {
+ for (int i = 0; i < 6; i++) {
+ futures.add(executor.submit(() ->
client.getBlockingStub(target)));
+ }
+ assertTrue("one refresh should be waiting in the delayed resolver",
+ client.delayedResolutionStarted.await(5,
TimeUnit.SECONDS));
+ for (Future<AbstractBlockingStub> future : futures) {
+ assertNotNull("no caller may block on the delayed refresh",
+ future.get(5, TimeUnit.SECONDS));
+ }
+ assertTrue("the existing healthy pool must stay live during
refresh",
+ allChannelsAreLive(oldChannels));
+
+ client.releaseDelayedResolution.countDown();
+ ManagedChannel[] currentChannels =
+ awaitPoolReplacement(client, target, oldChannels);
+ assertTrue("the previous pool must be retired after replacement is
published",
+ allChannelsAreShutdown(oldChannels));
+ assertTrue("the replacement pool must be live",
allChannelsAreLive(currentChannels));
+ assertEquals("concurrent callers must share a single refresh
resolution",
+ resolutionsBeforeConcurrentCalls + 1,
client.resolutionCount.get());
+ } finally {
+ client.releaseDelayedResolution.countDown();
+ executor.shutdownNow();
+ }
+ }
+
+ @Test
+ public void testRefreshGracefullyRetiresActiveStreamChannels() throws
Exception {
+ String target = uniqueTarget("active-stream-refresh");
+ RecordingGrpcClient client = new RecordingGrpcClient();
+ client.activeCallsFinished = new CountDownLatch(1);
+ client.drainTimeoutNanos = TimeUnit.SECONDS.toNanos(5L);
+ ManagedChannel[] oldChannels = client.getChannels(target);
+ AbstractAsyncStub activeStreamStub = client.getAsyncStub(target);
+ assertTrue("the simulated active stream must be on the old pool",
+ belongsToPool(activeStreamStub.getChannel(), oldChannels));
+
+ client.resolvedTarget = "10.0.0.2";
+ ManagedChannel[] newChannels = awaitPoolReplacement(client, target,
oldChannels);
+ List<FakeManagedChannel> retiredChannels = fakeChannels(oldChannels);
+ assertTrue("the retired pool must receive graceful shutdown",
+
retiredChannels.stream().allMatch(FakeManagedChannel::isShutdown));
+ assertFalse("active streams must not be force closed immediately",
+
retiredChannels.stream().anyMatch(FakeManagedChannel::isForceShutdown));
+ client.activeCallsFinished.countDown();
+ awaitCondition("retired channels should terminate after active calls
finish",
+ () ->
retiredChannels.stream().allMatch(FakeManagedChannel::isTerminated));
+ assertFalse("drained channels must not need forced shutdown",
+
retiredChannels.stream().anyMatch(FakeManagedChannel::isForceShutdown));
+ assertTrue("the replacement pool must remain live",
allChannelsAreLive(newChannels));
}
/**
- * A client whose channels and stubs are local fakes, so the test needs no
PD or store node.
+ * Holds a stub pool build open, refreshes the pool underneath it, and
asserts that both the
+ * interleaved build and a concurrent one return stubs bound to the
published pool. Blocking
+ * and asynchronous acquisition share one implementation, so the
asynchronous path stands in
+ * for both; it is the one that also publishes a stub cache worth
asserting on.
*/
+ @Test
+ public void testStubBuildRetriesAfterChannelRefresh() throws Exception {
+ String target = uniqueTarget("concurrent-stub-refresh");
+ StubInterleavingGrpcClient client = new StubInterleavingGrpcClient();
+ ManagedChannel[] oldChannels = client.getChannels(target);
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+
+ try {
+ Future<AbstractAsyncStub> staleStub =
+ executor.submit(() -> client.getAsyncStub(target));
+ assertTrue("the old stub pool build must be in flight",
+ client.staleStubBuildStarted.await(5,
TimeUnit.SECONDS));
+ client.resolvedTarget = "10.0.0.2";
+ Future<AbstractAsyncStub> freshStub =
+ executor.submit(() -> client.getAsyncStub(target));
+ awaitCondition("refresh must retire the old channel pool",
+ () -> allChannelsAreShutdown(oldChannels));
+ client.releaseStaleStubBuild.countDown();
+
+ ManagedChannel[] currentChannels = client.getChannels(target);
+ assertTrue("the stale build must retry against the current pool",
+ belongsToPool(staleStub.get(5,
TimeUnit.SECONDS).getChannel(),
+ currentChannels));
+ assertTrue("the concurrent build must use the current pool",
+ belongsToPool(freshStub.get(5,
TimeUnit.SECONDS).getChannel(),
+ currentChannels));
+ assertTrue("the current channel pool must remain live",
+ allChannelsAreLive(currentChannels));
+ assertCachedChannelsCurrentAndLive(
+ "the final stub cache must only reference current live
channels",
+ cachedAsyncStubChannels(client, target), currentChannels);
+ } finally {
+ client.releaseStaleStubBuild.countDown();
+ executor.shutdownNow();
+ }
+ }
+
+ /**
+ * QueryV2 used to take a channel straight from the pool and build its
stub afterwards, which
+ * let a refresh retire that channel in between. It must now go through
the guarded path.
+ */
+ @Test
+ public void testQueryV2StubFollowsPublishedPoolAcrossRefresh() throws
Exception {
+ String target = uniqueTarget("query-v2-refresh");
+ QueryV2TestClient client = new QueryV2TestClient();
+ ManagedChannel[] oldChannels = client.getChannels(target);
+ ExecutorService executor = Executors.newSingleThreadExecutor();
+
+ try {
+ /*
+ * Interleave a refresh with the stub build. Taking a channel from
the pool and
+ * building the stub afterwards would bind it to a channel retired
in between.
+ */
+ Future<QueryServiceGrpc.QueryServiceStub> stub =
+ executor.submit(() -> client.getQueryServiceStub(target));
+ assertTrue("the QueryV2 stub build must be in flight",
+ client.stubBuildStarted.await(5, TimeUnit.SECONDS));
+ client.resolvedTarget = "10.0.0.2";
+ // getChannels is what triggers a refresh, and the blocked build
cannot call it.
+ awaitCondition("refresh must retire the old channel pool",
+ () -> client.getChannels(target) != oldChannels &&
+ allChannelsAreShutdown(oldChannels));
+ client.releaseStubBuild.countDown();
+
+ ManagedChannel[] newChannels = client.getChannels(target);
+ Channel channel = stub.get(5, TimeUnit.SECONDS).getChannel();
+ assertTrue("QueryV2 must never return a stub bound to a retired
channel",
+ belongsToPool(channel, newChannels));
+ assertFalse("QueryV2 must never return a stub on a shut down
channel",
+ ((ManagedChannel) channel).isShutdown());
+
+ List<ManagedChannel> stubChannels = new ArrayList<>();
+ for (int i = 0; i < AbstractGrpcClient.concurrency; i++) {
+ stubChannels.add((ManagedChannel)
client.getQueryServiceStub(target).getChannel());
+ }
+ assertCachedChannelsCurrentAndLive("QueryV2 stubs must stay on the
published pool",
+ stubChannels, newChannels);
+ assertUsesEveryChannel("QueryV2 stubs must still spread across the
pool",
+ stubChannels, newChannels);
+ } finally {
+ client.releaseStubBuild.countDown();
+ executor.shutdownNow();
+ }
+ }
+
+ @Test
+ public void testInjectedQueryV2ChannelBypassesRefreshRetirement() throws
Exception {
+ String target = uniqueTarget("query-v2-injected-channel");
+ FakeManagedChannel injected = new FakeManagedChannel(target);
+ InjectedChannelQueryV2Client client = new
InjectedChannelQueryV2Client();
+ QueryV2Client.setTestChannel(injected);
+
+ try {
+ QueryServiceGrpc.QueryServiceStub stub =
client.getQueryServiceStub(target);
+ assertSame("the QueryV2 stub must use the injected channel",
injected,
+ stub.getChannel());
+
+ /*
+ * Without the injected-channel resolution guard, the delayed
resolution lands after
+ * the first pool is published, rebuilds that pool with the same
injected channel,
+ * and retires the channel that the replacement still references.
+ */
+ client.releaseResolution.countDown();
+ awaitCondition("the injected-channel refresh must settle",
+ () -> refreshIsIdle(client, target));
+ assertFalse("refresh must not retire an injected channel",
injected.isShutdown());
+ assertSame("the cached stub must retain the live injected
channel", injected,
+ client.getQueryServiceStub(target).getChannel());
+ } finally {
+ client.releaseResolution.countDown();
+ QueryV2Client.setTestChannel(null);
+ }
+ }
+
+ @Test
+ public void testQueryV2WithoutInjectedChannelUsesDnsResolution() {
Review Comment:
Fixed in fad20a3c. The regression now invokes the public
ResolvingQueryV2Client getQueryServiceStub(String) path with deterministic fake
channel construction, then asserts the returned stub belongs to the published
live pool. The final focused suite is 29/29 with zero failures/errors/skips.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]