This is an automated email from the ASF dual-hosted git repository.
Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git
The following commit(s) were added to refs/heads/master by this push:
new 115ea34a6e fix(admin): bound ai proxy key resolution and retry
caching. (#7598)
115ea34a6e is described below
commit 115ea34a6e105a057d621f0619661c08a7d9ed36
Author: redyyang <[email protected]>
AuthorDate: Thu Oct 15 14:07:06 2026 +0800
fix(admin): bound ai proxy key resolution and retry caching. (#7598)
Refs #7591
Co-authored-by: redyyang <[email protected]>
---
.../service/support/AiProxyRealKeyResolver.java | 146 +++++++----
.../support/AiProxyRealKeyResolverTest.java | 278 ++++++++++++++++++++-
2 files changed, 367 insertions(+), 57 deletions(-)
diff --git
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/support/AiProxyRealKeyResolver.java
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/support/AiProxyRealKeyResolver.java
index cadc278b2b..8bef58744f 100644
---
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/support/AiProxyRealKeyResolver.java
+++
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/support/AiProxyRealKeyResolver.java
@@ -19,6 +19,8 @@ package org.apache.shenyu.admin.service.support;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
import jakarta.annotation.PreDestroy;
import org.apache.commons.lang3.StringUtils;
import org.apache.shenyu.admin.mapper.SelectorMapper;
@@ -36,18 +38,19 @@ import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
/**
* AiProxyRealKeyResolver resolves real api key from selector handle JSON.
- * Event-driven invalidation, single-flight resolve, no time TTL by default.
+ * Bounded caching with event-driven invalidation, single-flight resolve and a
short failure cooldown.
*/
@Component
public class AiProxyRealKeyResolver {
@@ -56,23 +59,36 @@ public class AiProxyRealKeyResolver {
private static final long RESOLVE_TIMEOUT_SECONDS = 2L;
+ private static final int RESOLVER_THREADS = 10;
+
+ private static final int RESOLVE_QUEUE_CAPACITY = 1024;
+
+ private static final long CACHE_MAXIMUM_SIZE = 10000L;
+
+ private static final long FAILURE_CACHE_SECONDS = 5L;
+
private final SelectorMapper selectorMapper;
private final ObjectMapper objectMapper;
- /** selectorId -> cached real key (nullable). */
- private final ConcurrentHashMap<String, AtomicReference<String>> cache =
new ConcurrentHashMap<>();
+ /** selectorId -> cached real key. */
+ private final Cache<String, String> cache =
Caffeine.newBuilder().maximumSize(CACHE_MAXIMUM_SIZE).build();
+
+ /** Failed or empty resolutions are retried after a short cooldown. */
+ private final Cache<String, Boolean> failures =
Caffeine.newBuilder().maximumSize(CACHE_MAXIMUM_SIZE)
+ .expireAfterWrite(FAILURE_CACHE_SECONDS, TimeUnit.SECONDS).build();
- /** selectorId -> in-flight resolving future. */
- private final Map<String, CompletableFuture<String>> inFlight = new
ConcurrentHashMap<>();
+ /** selectorId -> in-flight resolving future, guarded by its own monitor.
*/
+ private final Map<String, CompletableFuture<String>> inFlight = new
HashMap<>();
private final ExecutorService executorService;
public AiProxyRealKeyResolver(final SelectorMapper selectorMapper, final
ObjectMapper objectMapper) {
this.selectorMapper = selectorMapper;
this.objectMapper = objectMapper;
- this.executorService = Executors.newFixedThreadPool(10,
- ShenyuThreadFactory.create("ai-proxy-key-resolver", false));
+ this.executorService = new ThreadPoolExecutor(RESOLVER_THREADS,
RESOLVER_THREADS, 0L, TimeUnit.MILLISECONDS,
+ new ArrayBlockingQueue<>(RESOLVE_QUEUE_CAPACITY),
ShenyuThreadFactory.create("ai-proxy-key-resolver", false),
+ new ThreadPoolExecutor.AbortPolicy());
}
/**
@@ -85,29 +101,7 @@ public class AiProxyRealKeyResolver {
if (StringUtils.isBlank(selectorId)) {
return Optional.empty();
}
- final AtomicReference<String> ref = cache.get(selectorId);
- if (Objects.nonNull(ref)) {
- final String v = ref.get();
- LOG.debug("[AiProxyRealKeyResolver] cache hit selectorId={},
masked={}...", selectorId, mask(v));
- return Optional.ofNullable(ref.get());
- }
- // single-flight: only one resolving task per selectorId
- final CompletableFuture<String> future =
inFlight.computeIfAbsent(selectorId,
- id -> CompletableFuture.supplyAsync(() -> doResolve(id),
executorService)
- .whenComplete((val, ex) -> {
- try {
- if (Objects.nonNull(ex)) {
- LOG.warn("[AiProxyRealKeyResolver] resolve
failed for selectorId={}: {}", id,
- ex.getMessage());
- } else {
- cache.put(id, new AtomicReference<>(val));
- LOG.info("[AiProxyRealKeyResolver]
resolved selectorId={}, masked={}...", id,
- mask(val));
- }
- } finally {
- inFlight.remove(id);
- }
- }));
+ final CompletableFuture<String> future =
getOrStartResolution(selectorId);
try {
return Optional.ofNullable(future.get(RESOLVE_TIMEOUT_SECONDS,
TimeUnit.SECONDS));
} catch (InterruptedException ex) {
@@ -122,6 +116,61 @@ public class AiProxyRealKeyResolver {
LOG.warn("[AiProxyRealKeyResolver] resolve failed for
selectorId={}: {}", selectorId,
Objects.nonNull(ex.getCause()) ?
ex.getCause().getMessage() : ex.getMessage());
return Optional.empty();
+ } catch (CancellationException ex) {
+ return Optional.empty();
+ }
+ }
+
+ private CompletableFuture<String> getOrStartResolution(final String
selectorId) {
+ synchronized (inFlight) {
+ final String cached = cache.getIfPresent(selectorId);
+ if (Objects.nonNull(cached)) {
+ LOG.debug("[AiProxyRealKeyResolver] cache hit selectorId={},
masked={}...", selectorId, mask(cached));
+ return CompletableFuture.completedFuture(cached);
+ }
+ if (Objects.nonNull(failures.getIfPresent(selectorId))) {
+ return CompletableFuture.completedFuture(null);
+ }
+ final CompletableFuture<String> existing =
inFlight.get(selectorId);
+ if (Objects.nonNull(existing)) {
+ return existing;
+ }
+ // Register before submitting so even immediate completion can
remove the flight safely.
+ final CompletableFuture<String> future = new CompletableFuture<>();
+ inFlight.put(selectorId, future);
+ try {
+ CompletableFuture.supplyAsync(() -> doResolve(selectorId),
executorService)
+ .whenComplete((value, error) ->
completeResolution(selectorId, future, value, error));
+ } catch (RejectedExecutionException ex) {
+ completeResolution(selectorId, future, null, ex);
+ }
+ return future;
+ }
+ }
+
+ private void completeResolution(final String selectorId, final
CompletableFuture<String> future, final String value, final Throwable error) {
+ synchronized (inFlight) {
+ // An invalidated task must neither overwrite the cache nor remove
a newer flight.
+ if (inFlight.remove(selectorId, future)) {
+ cacheResult(selectorId, Objects.isNull(error) ? value : null);
+ if (Objects.isNull(error)) {
+ LOG.info("[AiProxyRealKeyResolver] resolved selectorId={},
masked={}...", selectorId, mask(value));
+ }
+ }
+ if (Objects.isNull(error)) {
+ future.complete(value);
+ } else {
+ future.completeExceptionally(error);
+ }
+ }
+ }
+
+ private void cacheResult(final String selectorId, final String value) {
+ if (Objects.isNull(value)) {
+ failures.put(selectorId, Boolean.TRUE);
+ } else {
+ cache.put(selectorId, value);
+ failures.invalidate(selectorId);
}
}
@@ -132,10 +181,13 @@ public class AiProxyRealKeyResolver {
*/
public void invalidate(final String selectorId) {
if (StringUtils.isNotBlank(selectorId)) {
- cache.remove(selectorId);
- final CompletableFuture<String> f = inFlight.remove(selectorId);
- if (Objects.nonNull(f)) {
- f.cancel(true);
+ synchronized (inFlight) {
+ cache.invalidate(selectorId);
+ failures.invalidate(selectorId);
+ final CompletableFuture<String> f =
inFlight.remove(selectorId);
+ if (Objects.nonNull(f)) {
+ f.cancel(true);
+ }
}
}
}
@@ -214,9 +266,11 @@ public class AiProxyRealKeyResolver {
final Set<String> missing = new HashSet<>();
for (String id : selectorIds) {
- AtomicReference<String> ref = cache.get(id);
- if (Objects.nonNull(ref)) {
- result.put(id, ref.get());
+ String value = cache.getIfPresent(id);
+ if (Objects.nonNull(value)) {
+ result.put(id, value);
+ } else if (Objects.nonNull(failures.getIfPresent(id))) {
+ result.put(id, null);
} else {
missing.add(id);
}
@@ -227,16 +281,14 @@ public class AiProxyRealKeyResolver {
if (Objects.nonNull(selectors)) {
for (SelectorDO selector : selectors) {
String apiKey = extractApiKey(selector.getHandle());
- cache.put(selector.getId(), new AtomicReference<>(apiKey));
+ cacheResult(selector.getId(), apiKey);
result.put(selector.getId(), apiKey);
}
}
- // Cache null for IDs not found in DB to avoid repeated DB hits.
+ // Briefly cache missing IDs, allowing recovery without a selector
change event.
for (String id : missing) {
if (!result.containsKey(id)) {
- // If not found in DB or handle is invalid, it wasn't
added to result.
- // We should add it as null to result and cache.
- cache.put(id, new AtomicReference<>(null));
+ cacheResult(id, null);
result.put(id, null);
}
}
@@ -266,4 +318,4 @@ public class AiProxyRealKeyResolver {
}
return v.substring(0, Math.min(6, v.length()));
}
-}
\ No newline at end of file
+}
diff --git
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/support/AiProxyRealKeyResolverTest.java
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/support/AiProxyRealKeyResolverTest.java
index 1360a07392..7f0a56345b 100644
---
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/support/AiProxyRealKeyResolverTest.java
+++
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/support/AiProxyRealKeyResolverTest.java
@@ -18,33 +18,56 @@
package org.apache.shenyu.admin.service.support;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
import org.apache.shenyu.admin.mapper.SelectorMapper;
import org.apache.shenyu.admin.model.entity.SelectorDO;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.util.ReflectionTestUtils;
+import java.time.Duration;
+import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.Set;
-
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import static org.awaitility.Awaitility.await;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.hasEntry;
import static org.hamcrest.Matchers.hasKey;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.nullValue;
+import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
- * Test cases for AiProxyRealKeyResolver.resolveRealKeys method.
+ * Test cases for bounded AI proxy key resolution and caching.
*/
@ExtendWith(MockitoExtension.class)
public final class AiProxyRealKeyResolverTest {
@@ -53,8 +76,6 @@ public final class AiProxyRealKeyResolverTest {
private static final String SELECTOR_ID_2 = "selector-2";
- private static final String SELECTOR_ID_3 = "selector-3";
-
private static final String SELECTOR_ID_NOT_EXIST = "selector-not-exist";
private static final String API_KEY_1 = "api-key-1";
@@ -72,16 +93,25 @@ public final class AiProxyRealKeyResolverTest {
private AiProxyRealKeyResolver resolver;
+ private AtomicLong ticker;
+
@BeforeEach
public void setUp() {
// Use real ObjectMapper for JSON parsing
objectMapper = new ObjectMapper();
resolver = new AiProxyRealKeyResolver(selectorMapper, objectMapper);
- // Reset cache before each test
- resolver.invalidate(SELECTOR_ID_1);
- resolver.invalidate(SELECTOR_ID_2);
- resolver.invalidate(SELECTOR_ID_3);
- resolver.invalidate(SELECTOR_ID_NOT_EXIST);
+ ticker = new AtomicLong();
+ final Cache<?, ?> failures = getCache("failures");
+ final Cache<String, Boolean> timedFailures = Caffeine.newBuilder()
+
.maximumSize(failures.policy().eviction().orElseThrow().getMaximum())
+
.expireAfterWrite(failures.policy().expireAfterWrite().orElseThrow().getExpiresAfter(TimeUnit.NANOSECONDS),
TimeUnit.NANOSECONDS)
+ .ticker(ticker::get).build();
+ ReflectionTestUtils.setField(resolver, "failures", timedFailures);
+ }
+
+ @AfterEach
+ public void tearDown() {
+ resolver.destroy();
}
@Test
@@ -248,6 +278,235 @@ public final class AiProxyRealKeyResolverTest {
assertThat(result.get(SELECTOR_ID_1), is(nullValue()));
}
+ @Test
+ public void testResolveRealKeyFailureCooldownAndRecovery() {
+ when(selectorMapper.selectById(SELECTOR_ID_1)).thenThrow(new
IllegalStateException("database unavailable"))
+ .thenReturn(buildSelectorDO(SELECTOR_ID_1, HANDLE_JSON_1));
+
+ assertThat(resolver.resolveRealKey(SELECTOR_ID_1),
is(Optional.empty()));
+ assertThat(resolver.resolveRealKey(SELECTOR_ID_1),
is(Optional.empty()));
+
assertThat(resolver.resolveRealKeys(Set.of(SELECTOR_ID_1)).get(SELECTOR_ID_1),
is(nullValue()));
+ verify(selectorMapper, times(1)).selectById(SELECTOR_ID_1);
+ verify(selectorMapper, never()).selectByIdSet(any());
+
+ ticker.addAndGet(TimeUnit.SECONDS.toNanos(5));
+ assertThat(resolver.resolveRealKey(SELECTOR_ID_1),
is(Optional.of(API_KEY_1)));
+ verify(selectorMapper, times(2)).selectById(SELECTOR_ID_1);
+ assertThat(getInFlight(), is(Collections.emptyMap()));
+ }
+
+ @Test
+ public void testResolveRealKeyEmptyResultCooldownExpires() {
+ when(selectorMapper.selectById(SELECTOR_ID_1)).thenReturn(null,
buildSelectorDO(SELECTOR_ID_1, HANDLE_JSON_1));
+
+ assertThat(resolver.resolveRealKey(SELECTOR_ID_1),
is(Optional.empty()));
+ assertThat(resolver.resolveRealKey(SELECTOR_ID_1),
is(Optional.empty()));
+ verify(selectorMapper, times(1)).selectById(SELECTOR_ID_1);
+
+ ticker.addAndGet(TimeUnit.SECONDS.toNanos(5));
+ assertThat(resolver.resolveRealKey(SELECTOR_ID_1),
is(Optional.of(API_KEY_1)));
+ verify(selectorMapper, times(2)).selectById(SELECTOR_ID_1);
+ }
+
+ @Test
+ public void testResolveRealKeysEmptyResultCooldownAndRecovery() {
+ final Set<String> selectorIds = Set.of(SELECTOR_ID_1, SELECTOR_ID_2);
+
when(selectorMapper.selectByIdSet(selectorIds)).thenReturn(Collections.emptyList())
+ .thenReturn(List.of(buildSelectorDO(SELECTOR_ID_1,
HANDLE_JSON_1), buildSelectorDO(SELECTOR_ID_2, HANDLE_JSON_2)));
+
+ assertThat(resolver.resolveRealKeys(selectorIds).get(SELECTOR_ID_1),
is(nullValue()));
+ assertThat(resolver.resolveRealKeys(selectorIds).get(SELECTOR_ID_2),
is(nullValue()));
+ assertThat(resolver.resolveRealKey(SELECTOR_ID_1),
is(Optional.empty()));
+ verify(selectorMapper, times(1)).selectByIdSet(selectorIds);
+ verify(selectorMapper, never()).selectById(any());
+
+ ticker.addAndGet(TimeUnit.SECONDS.toNanos(5));
+ assertThat(resolver.resolveRealKeys(selectorIds),
hasEntry(SELECTOR_ID_1, API_KEY_1));
+ verify(selectorMapper, times(2)).selectByIdSet(selectorIds);
+ }
+
+ @Test
+ public void testInvalidateClearsFailureCooldown() {
+ when(selectorMapper.selectById(SELECTOR_ID_1)).thenThrow(new
IllegalStateException("database unavailable"))
+ .thenReturn(buildSelectorDO(SELECTOR_ID_1, HANDLE_JSON_1));
+
+ assertThat(resolver.resolveRealKey(SELECTOR_ID_1),
is(Optional.empty()));
+ resolver.invalidate(SELECTOR_ID_1);
+ assertThat(resolver.resolveRealKey(SELECTOR_ID_1),
is(Optional.of(API_KEY_1)));
+ verify(selectorMapper, times(2)).selectById(SELECTOR_ID_1);
+ }
+
+ @Test
+ public void testRefreshReplacesCachedKey() {
+
when(selectorMapper.selectById(SELECTOR_ID_1)).thenReturn(buildSelectorDO(SELECTOR_ID_1,
HANDLE_JSON_1),
+ buildSelectorDO(SELECTOR_ID_1, HANDLE_JSON_2));
+
+ assertThat(resolver.resolveRealKey(SELECTOR_ID_1),
is(Optional.of(API_KEY_1)));
+ ticker.addAndGet(TimeUnit.DAYS.toNanos(1));
+ assertThat(resolver.resolveRealKey(SELECTOR_ID_1),
is(Optional.of(API_KEY_1)));
+ assertThat(resolver.refresh(SELECTOR_ID_1),
is(Optional.of(API_KEY_2)));
+ verify(selectorMapper, times(2)).selectById(SELECTOR_ID_1);
+ }
+
+ @Test
+ public void testCachesStayBoundedUnderSelectorChurn() {
+ final Set<String> selectorIds = IntStream.range(0, 10010).mapToObj(id
-> "selector-" + id).collect(Collectors.toSet());
+ final List<SelectorDO> selectors = selectorIds.stream().map(id ->
buildSelectorDO(id, HANDLE_JSON_1)).collect(Collectors.toList());
+ when(selectorMapper.selectByIdSet(selectorIds)).thenReturn(selectors);
+
+ assertThat(resolver.resolveRealKeys(selectorIds).size(),
is(selectorIds.size()));
+ final Cache<?, ?> keys = getCache("cache");
+ keys.cleanUp();
+ assertTrue(keys.estimatedSize() <= 10000L);
+
+ final Set<String> missingIds = selectorIds.stream().map(id ->
"missing-" + id).collect(Collectors.toSet());
+
when(selectorMapper.selectByIdSet(missingIds)).thenReturn(Collections.emptyList());
+ assertThat(resolver.resolveRealKeys(missingIds).size(),
is(missingIds.size()));
+ final Cache<?, ?> failures = getCache("failures");
+ failures.cleanUp();
+ assertTrue(failures.estimatedSize() <= 10000L);
+ }
+
+ @Test
+ public void testQueueSaturationFailsFastWithoutLeakingFlight() throws
InterruptedException {
+ final ThreadPoolExecutor executor = (ThreadPoolExecutor)
ReflectionTestUtils.getField(resolver, "executorService");
+ final CountDownLatch started = new CountDownLatch(10);
+ final CountDownLatch release = new CountDownLatch(1);
+ try {
+ for (int index = 0; index < 10; index++) {
+ executor.execute(() -> {
+ started.countDown();
+ awaitRelease(release);
+ });
+ }
+ assertTrue(started.await(1, TimeUnit.SECONDS));
+ for (int index = 0; index < 1024; index++) {
+ executor.execute(() -> { });
+ }
+
+ assertTimeoutPreemptively(Duration.ofSeconds(1), () ->
assertThat(resolver.resolveRealKey(SELECTOR_ID_1), is(Optional.empty())));
+ assertThat(resolver.resolveRealKey(SELECTOR_ID_1),
is(Optional.empty()));
+ assertThat(executor.getQueue().size(), is(1024));
+ assertThat(getInFlight(), is(Collections.emptyMap()));
+ verify(selectorMapper, never()).selectById(any());
+ } finally {
+ release.countDown();
+ }
+ }
+
+ @Test
+ public void testImmediateCompletionRemovesFlight() {
+ final ExecutorService executor = mock(ExecutorService.class);
+ doAnswer(invocation -> {
+ invocation.getArgument(0, Runnable.class).run();
+ return null;
+ }).when(executor).execute(any(Runnable.class));
+ replaceExecutor(executor);
+
when(selectorMapper.selectById(SELECTOR_ID_1)).thenReturn(buildSelectorDO(SELECTOR_ID_1,
HANDLE_JSON_1));
+
+ assertThat(resolver.resolveRealKey(SELECTOR_ID_1),
is(Optional.of(API_KEY_1)));
+ assertThat(getInFlight(), is(Collections.emptyMap()));
+ assertThat(resolver.resolveRealKey(SELECTOR_ID_1),
is(Optional.of(API_KEY_1)));
+ verify(selectorMapper, times(1)).selectById(SELECTOR_ID_1);
+ }
+
+ @Test
+ public void testConcurrentCallsShareOneFlight() throws Exception {
+ final CountDownLatch release = new CountDownLatch(1);
+ final List<Thread> callerThreads = new CopyOnWriteArrayList<>();
+ final ExecutorService callers = Executors.newFixedThreadPool(4,
runnable -> {
+ final Thread thread = new Thread(runnable);
+ callerThreads.add(thread);
+ return thread;
+ });
+ when(selectorMapper.selectById(SELECTOR_ID_1)).thenAnswer(invocation
-> {
+ awaitRelease(release);
+ return buildSelectorDO(SELECTOR_ID_1, HANDLE_JSON_1);
+ });
+ try {
+ final List<Future<Optional<String>>> results = new ArrayList<>();
+ for (int index = 0; index < 4; index++) {
+ results.add(callers.submit(() ->
resolver.resolveRealKey(SELECTOR_ID_1)));
+ }
+ await().atMost(Duration.ofSeconds(1)).until(() ->
callerThreads.size() == 4
+ && callerThreads.stream().allMatch(thread ->
thread.getState() == Thread.State.TIMED_WAITING));
+ release.countDown();
+ for (Future<Optional<String>> result : results) {
+ assertThat(result.get(1, TimeUnit.SECONDS),
is(Optional.of(API_KEY_1)));
+ }
+ verify(selectorMapper, times(1)).selectById(SELECTOR_ID_1);
+ assertThat(getInFlight(), is(Collections.emptyMap()));
+ } finally {
+ release.countDown();
+ callers.shutdownNow();
+ }
+ }
+
+ @Test
+ public void testInvalidatedTaskCannotOverwriteCacheOrRemoveNewFlight()
throws Exception {
+ final ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 1, 0L,
TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(2));
+ replaceExecutor(executor);
+ final ExecutorService callers = Executors.newFixedThreadPool(2);
+ final CountDownLatch oldStarted = new CountDownLatch(1);
+ final CountDownLatch oldRelease = new CountDownLatch(1);
+ final CountDownLatch newStarted = new CountDownLatch(1);
+ final CountDownLatch newRelease = new CountDownLatch(1);
+ when(selectorMapper.selectById(SELECTOR_ID_1)).thenAnswer(invocation
-> {
+ oldStarted.countDown();
+ awaitRelease(oldRelease);
+ return buildSelectorDO(SELECTOR_ID_1, HANDLE_JSON_1);
+ }).thenAnswer(invocation -> {
+ newStarted.countDown();
+ awaitRelease(newRelease);
+ return buildSelectorDO(SELECTOR_ID_1, HANDLE_JSON_2);
+ });
+ try {
+ final Future<Optional<String>> oldResult = callers.submit(() ->
resolver.resolveRealKey(SELECTOR_ID_1));
+ assertTrue(oldStarted.await(1, TimeUnit.SECONDS));
+ resolver.invalidate(SELECTOR_ID_1);
+ assertThat(oldResult.get(1, TimeUnit.SECONDS),
is(Optional.empty()));
+ final Future<Optional<String>> newResult = callers.submit(() ->
resolver.resolveRealKey(SELECTOR_ID_1));
+ await().atMost(Duration.ofSeconds(1)).until(() ->
executor.getQueue().size() == 1);
+ oldRelease.countDown();
+ assertTrue(newStarted.await(1, TimeUnit.SECONDS));
+ assertThat(getInFlight().size(), is(1));
+ final Future<Optional<String>> sharedResult = callers.submit(() ->
resolver.resolveRealKey(SELECTOR_ID_1));
+ newRelease.countDown();
+ assertThat(newResult.get(1, TimeUnit.SECONDS),
is(Optional.of(API_KEY_2)));
+ assertThat(sharedResult.get(1, TimeUnit.SECONDS),
is(Optional.of(API_KEY_2)));
+ assertThat(resolver.resolveRealKey(SELECTOR_ID_1),
is(Optional.of(API_KEY_2)));
+ verify(selectorMapper, times(2)).selectById(SELECTOR_ID_1);
+ assertThat(getInFlight(), is(Collections.emptyMap()));
+ } finally {
+ oldRelease.countDown();
+ newRelease.countDown();
+ callers.shutdownNow();
+ }
+ }
+
+ private Cache<?, ?> getCache(final String field) {
+ return (Cache<?, ?>) ReflectionTestUtils.getField(resolver, field);
+ }
+
+ private Map<?, ?> getInFlight() {
+ return (Map<?, ?>) ReflectionTestUtils.getField(resolver, "inFlight");
+ }
+
+ private void replaceExecutor(final ExecutorService executor) {
+ final ExecutorService original = (ExecutorService)
ReflectionTestUtils.getField(resolver, "executorService");
+ original.shutdownNow();
+ ReflectionTestUtils.setField(resolver, "executorService", executor);
+ }
+
+ private void awaitRelease(final CountDownLatch release) {
+ try {
+ assertTrue(release.await(5, TimeUnit.SECONDS));
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException(ex);
+ }
+ }
+
private SelectorDO buildSelectorDO(final String id, final String handle) {
SelectorDO selector = new SelectorDO();
selector.setId(id);
@@ -255,4 +514,3 @@ public final class AiProxyRealKeyResolverTest {
return selector;
}
}
-