Portal을 endpoint 원천으로 확정하고 route 단위 실패 격리를 적용한다
내부망 운영은 route↔Tool Service 매핑을 Portal이 소유하고, MCP 배포 하나가 N개 route를 서비스하며, route 하나에 N개 Tool Service가 붙을 수 있다. 이 판단을 ADR-0010으로 남기고 ADR-0007 전체와 ADR-0009 결정 4를 대체한다. 계약 - Portal-MCP registry 조회 계약 v0.1과 예제 JSON을 docs/contracts/portal-mcp/에 신설한다. 지금까지 이 경로에는 정본이 없었다. - 예제를 PortalToolRegistryClient의 실제 파싱 경로에 태우는 계약 테스트를 추가해 문서와 구현이 따로 표류하지 않게 한다. 실패 격리 - fetchAllTools()의 실패 전파를 route 단위로 격리한다. 계약 v0.2의 "aggregate는 전부 아니면 전무"는 카탈로그 하나를 전제한 규칙인데, route가 N개가 되면서 전 route로 확대돼 있었다. Tool Service 하나의 장애가 cold start에서 Pod 전체를 내리고 steady state에서 모든 route의 갱신을 멈추던 동작을 없앤다. - 제거 판단의 원천을 ToolRegistryClient.knownRoutes()로 분리한다. 조회 결과를 기준으로 지우면 이번 주기에 실패한 route의 정상 snapshot까지 사라져 "어떤 실패도 목록을 비우지 않는다" 불변식이 깨진다. - readiness는 최소 1개 route로 UP을 유지한다. 모든 route를 요구하면 정상 route까지 트래픽에서 빠져 위 격리를 되돌리기 때문이다. 대신 routesWithoutSnapshot을 health detail로 노출해 관제가 부분 상태를 감지하게 한다. 설정과 기동 - warm start가 route별 Redis key를 읽도록 확장하고, 읽을 key를 알기 위해 기동 preload 순서를 registry 조회 → warm start → manifest 조회로 바꾼다. - 어떤 코드도 읽지 않던 mcp.portal.route-key를 제거한다. route key는 요청 URI에서만 결정되며, 설정으로 보정하면 잘못된 단일 진입점 호출이 조용히 성공한다. 정리 - ToolRegistryService.java의 이중 인코딩으로 깨져 있던 한글 Javadoc 33줄을 코드 동작에 맞춰 다시 쓰고, replaceSnapshot 위에 겹쳐 있던 고아 Javadoc 블록을 지운다. - Helm chart는 mcp.bundles 구성에서 계속 유효하므로 삭제하지 않고, 내부망 운영 대상이 아니라는 사실을 deploy/README.md와 values.yaml에 명시한다. 검증: 이 환경은 loopback이 막혀 gradlew check를 실행하지 못했다. CodeStyleContract가 보는 항목(줄바꿈, 탭, 행말 공백, 파일 끝 개행, 미사용 import, import 순서)은 변경된 Java 13개 파일에 대해 따로 재현해 확인했다. 컴파일과 테스트 실행은 미확인이다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -41,7 +41,7 @@ public record McpProperties(
|
||||
*/
|
||||
public McpProperties {
|
||||
bundles = bundles == null ? List.of() : List.copyOf(bundles);
|
||||
portal = portal == null ? new Portal(false, "", "", 300) : portal;
|
||||
portal = portal == null ? new Portal(false, "", 300) : portal;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,7 +165,7 @@ public record McpProperties(
|
||||
* 포털이 소유한 Tool Service registry 조회 설정입니다.
|
||||
* MCP 요청을 직접 처리하지 않고 배경 refresh가 route별 Tool Service 위치와 revision을 읽을 때 사용합니다.
|
||||
*/
|
||||
public record Portal(boolean enabled, String routeKey, String registryUrl, @Min(1) long refreshIntervalSeconds) {
|
||||
public record Portal(boolean enabled, String registryUrl, @Min(1) long refreshIntervalSeconds) {
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,8 @@ package io.shinhanlife.dap.biz.mcp.observability;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryRefreshScheduler;
|
||||
import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.springframework.boot.actuate.health.Health;
|
||||
import org.springframework.boot.actuate.health.HealthIndicator;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -28,16 +30,28 @@ public class ToolCatalogHealthIndicator implements HealthIndicator {
|
||||
|
||||
/**
|
||||
* 기동 preload 시도가 끝났고 usable snapshot이 있을 때만 UP을 반환합니다. 조회 상태 외에 Tool 이름이나 개수 같은 카탈로그 내용은 노출하지 않습니다.
|
||||
*
|
||||
* <p>한 배포가 여러 route를 서비스하는 구성에서는 <b>route 하나만 준비돼도 UP</b>입니다.
|
||||
* readiness는 Pod 전체의 트래픽 게이트여서 route별 상태를 표현할 수 없고, 모든 route를 요구하면
|
||||
* Tool Service 하나의 장애가 정상 route까지 트래픽에서 제외해 장애 범위를 오히려 넓히기 때문입니다.
|
||||
* 대신 준비되지 않은 route를 detail로 노출해 관제가 부분 상태를 감지하게 합니다.
|
||||
*/
|
||||
@Override
|
||||
public Health health() {
|
||||
boolean firstAttemptCompleted = scheduler.firstAttemptCompleted();
|
||||
boolean usableSnapshot = registryService.hasUsableSnapshot();
|
||||
Set<String> readyRoutes = registryService.readyRouteKeys();
|
||||
List<String> pendingRoutes = registryService.knownRouteKeys().stream()
|
||||
.filter(routeKey -> !readyRoutes.contains(routeKey))
|
||||
.sorted()
|
||||
.toList();
|
||||
Health.Builder health = firstAttemptCompleted && usableSnapshot ? Health.up() : Health.down();
|
||||
return health.withDetail(
|
||||
"firstDiscoveryAttempt",
|
||||
firstAttemptCompleted ? "completed" : "pending")
|
||||
.withDetail("usableSnapshot", usableSnapshot)
|
||||
.withDetail("readyRoutes", readyRoutes.stream().sorted().toList())
|
||||
.withDetail("routesWithoutSnapshot", pendingRoutes)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,15 +74,39 @@ public class PortalToolRegistryClient implements ToolRegistryClient {
|
||||
/**
|
||||
* 포털 전체 registry snapshot API를 한 번 호출해 route별 Tool catalog를 구성합니다.
|
||||
* 응답의 {@code routes[]}에 있는 각 route마다 Tool Service manifest를 조회해 route별 in-memory snapshot 후보를 만듭니다.
|
||||
*
|
||||
* <p>한 route의 조회 실패는 그 route만 결과에서 빠뜨리고 나머지 route의 조회를 계속합니다.
|
||||
* "aggregate는 전부 아니면 전무"는 카탈로그 <b>하나</b>를 온전하게 유지하기 위한 규칙이므로 route 안에서만 적용해야 하며,
|
||||
* 여기서 예외를 그대로 올리면 Tool Service 하나의 장애가 전 route의 갱신을 멈춰 서로 다른 업무가 서로를 막습니다.
|
||||
* 빠진 route의 기존 snapshot을 지울지는 호출자가 {@link #knownRoutes()}로 판단합니다.
|
||||
*/
|
||||
@Override
|
||||
public Map<String, List<ToolMetadata>> fetchAllTools() {
|
||||
ensurePortalRegistryLoaded();
|
||||
Map<String, List<ToolMetadata>> snapshots = new LinkedHashMap<>();
|
||||
bundlesByRoute.forEach((routeKey, bundles) -> snapshots.put(routeKey, fetchRouteTools(routeKey, bundles)));
|
||||
bundlesByRoute.forEach((routeKey, bundles) -> {
|
||||
try {
|
||||
snapshots.put(routeKey, fetchRouteTools(routeKey, bundles));
|
||||
} catch (RuntimeException exception) {
|
||||
log.warn(
|
||||
"Portal route catalog refresh failed; other routes continue. routeKey={} reason={} message={}",
|
||||
routeKey,
|
||||
exception.getClass().getSimpleName(),
|
||||
exception.getMessage());
|
||||
}
|
||||
});
|
||||
return Map.copyOf(snapshots);
|
||||
}
|
||||
|
||||
/**
|
||||
* 포털 registry가 선언한 route key 전체를 반환합니다.
|
||||
* 조회 성공 여부와 무관하며, 포털 응답에서 사라진 route만 이 집합에서 빠집니다.
|
||||
*/
|
||||
@Override
|
||||
public Set<String> knownRoutes() {
|
||||
return Set.copyOf(bundlesByRoute.keySet());
|
||||
}
|
||||
|
||||
/**
|
||||
* 포털 registry API를 호출해 route별 Tool Server endpoint 목록만 memory에 갱신합니다.
|
||||
* manifest 조회는 수행하지 않으며, 실패하면 기존 endpoint 목록이나 Redis fallback 규칙을 호출자에게 전달합니다.
|
||||
|
||||
@@ -2,6 +2,7 @@ package io.shinhanlife.dap.biz.mcp.registry;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Tool metadata의 원천(source)을 읽는 역할입니다.
|
||||
@@ -35,6 +36,16 @@ public interface ToolRegistryClient {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 원천이 현재 알고 있는 route key 전체를 반환합니다.
|
||||
* {@link #fetchAllTools()}가 조회에 <b>성공한</b> route만 담는 것과 달리, 이 집합은 조회 성공 여부와 무관하게 원천이 선언한 route를 뜻합니다.
|
||||
* 호출자는 이 둘의 차이로 "이번에 조회가 실패한 route"와 "원천에서 사라진 route"를 구분하며, 전자의 기존 snapshot을 지우지 않습니다.
|
||||
* route 개념이 없는 구현은 빈 집합을 반환하고, 호출자는 기존 제거 규칙을 그대로 사용합니다.
|
||||
*/
|
||||
default Set<String> knownRoutes() {
|
||||
return Set.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* route 구분이 없는 기존 호출 경로를 위해 기본 route의 Tool 목록을 읽습니다.
|
||||
*/
|
||||
|
||||
@@ -28,13 +28,14 @@ public class ToolRegistryRefreshScheduler {
|
||||
}
|
||||
|
||||
/**
|
||||
* 애플리케이션 준비 직후 jitter 없이 첫 Tool snapshot을 best-effort 방식으로 미리 적재합니다. 먼저 다른 replica가 공유 cache에 남긴 snapshot으로 warm start해 기동 직후의 빈 목록 구간을 줄이고, 이어서 원천을 조회해 최신
|
||||
* 상태로 교체합니다. 두 단계 모두 실패해도 애플리케이션은 계속 기동합니다.
|
||||
* 애플리케이션 준비 직후 jitter 없이 첫 Tool snapshot을 best-effort 방식으로 미리 적재합니다.
|
||||
* 먼저 원천 registry에서 route 목록을 확보하고, 다른 replica가 공유 cache에 남긴 route별 snapshot으로 warm start해 기동 직후의 빈 목록 구간을 줄인 뒤, 원천을 조회해 최신 상태로 교체합니다.
|
||||
* route 목록을 모르면 어느 Redis key를 읽어야 할지 알 수 없으므로 registry 조회가 warm start보다 먼저 와야 하며, 세 단계가 모두 실패해도 애플리케이션은 계속 기동합니다.
|
||||
*/
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void preload() {
|
||||
safeWarmStart();
|
||||
safePortalRefresh("preload");
|
||||
safeWarmStart();
|
||||
safeManifestRefresh("preload");
|
||||
firstAttemptCompleted = true;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
@@ -20,7 +21,8 @@ import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Tool Registry metadata 議고쉶???⑥씪 吏꾩엯?먯씠硫??붿껌 寃쎈줈? 諛곌꼍 媛깆떊 寃쎈줈瑜?遺꾨━?섎뒗 ?쒕퉬?ㅼ엯?덈떎. {@code tools/list}? {@code tools/call}???붿껌 寃쎈줈??in-memory snapshot留??쎌쑝誘濡?Redis ?μ븷??吏?곗씠 ?묐떟?? * ?곹뼢??二쇱? ?딆뒿?덈떎. Redis??諛곌꼍 媛깆떊怨?warm start?먯꽌留??ъ슜?섎뒗 replica 媛?怨듭쑀 吏?먯씠硫? ?먯쿇 議고쉶 ?깃났 寃곌낵留???ν빀?덈떎. 二쇱슂 ?섏〈?깆? ?먯쿇 port {@link ToolRegistryClient}? ?좏깮??Redis cache?낅땲??
|
||||
* Tool Registry metadata 조회의 단일 진입점이며 요청 경로와 배경 갱신 경로를 분리하는 서비스입니다. {@code tools/list}와 {@code tools/call}의 요청 경로는 in-memory snapshot만 읽으므로 Redis 장애나 지연이 응답에
|
||||
* 영향을 주지 않습니다. Redis는 배경 갱신과 warm start에서만 쓰는 replica 간 공유 지점이며 원천 조회에 성공한 결과만 저장합니다. 주요 의존성은 원천 port {@link ToolRegistryClient}와 선택적 Redis cache입니다.
|
||||
*/
|
||||
@Service
|
||||
public class ToolRegistryService {
|
||||
@@ -36,7 +38,7 @@ public class ToolRegistryService {
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* ?먯쿇 Registry? memory쨌?좏깮??Redis 怨듭쑀 cache瑜?二쇱엯諛쏆뒿?덈떎.
|
||||
* 원천 Registry와 memory·선택적 Redis 공유 cache를 주입받습니다.
|
||||
*/
|
||||
public ToolRegistryService(
|
||||
ToolRegistryClient registryClient, Optional<RedisToolRegistryCache> redisCache) {
|
||||
@@ -45,8 +47,8 @@ public class ToolRegistryService {
|
||||
}
|
||||
|
||||
/**
|
||||
* ?먯쿇 Registry, ?좏깮??Redis 怨듭쑀 cache, Tool 紐⑸줉 蹂寃??대깽??諛쒗뻾?먮? 二쇱엯諛쏆뒿?덈떎.
|
||||
* Spring 湲곕룞 ???몄텧?섎ʼn, refresh ?깃났?쇰줈 湲곗〈 route snapshot???щ씪吏??뚮쭔 ?대깽?몃? 諛쒗뻾?⑸땲??
|
||||
* 원천 Registry, 선택적 Redis 공유 cache, Tool 목록 변경 이벤트 발행자를 주입받습니다.
|
||||
* Spring 기동 시 호출되며, refresh 성공으로 기존 route snapshot이 달라졌을 때만 이벤트를 발행합니다.
|
||||
*/
|
||||
public ToolRegistryService(
|
||||
ToolRegistryClient registryClient,
|
||||
@@ -56,8 +58,8 @@ public class ToolRegistryService {
|
||||
}
|
||||
|
||||
/**
|
||||
* ?먯쿇 Registry, ?좏깮??Redis cache, 蹂寃??대깽??諛쒗뻾?? JSON 吏곷젹???꾧뎄瑜?二쇱엯諛쏆뒿?덈떎.
|
||||
* Spring 湲곕룞 ???몄텧?섎ʼn snapshot 蹂寃?寃利?濡쒓렇瑜?JSON ?뺥깭濡??④만 ???덇쾶 ObjectMapper瑜?蹂닿??⑸땲??
|
||||
* 원천 Registry, 선택적 Redis cache, 변경 이벤트 발행자, JSON 직렬화 도구를 주입받습니다.
|
||||
* Spring 기동 시 호출되며 snapshot 변경 검증 로그를 JSON 형태로 남길 수 있게 ObjectMapper를 보관합니다.
|
||||
*/
|
||||
@Autowired
|
||||
public ToolRegistryService(
|
||||
@@ -72,15 +74,16 @@ public class ToolRegistryService {
|
||||
}
|
||||
|
||||
/**
|
||||
* ?쒖꽦 Tool 紐⑸줉??in-memory snapshot?먯꽌 ?쎌뒿?덈떎. ?붿껌 寃쎈줈?먯꽌??Redis瑜??몄텧?섏? ?딆쑝誘濡?Redis ?μ븷??吏?곗씠 {@code tools/list} ?묐떟 ?쒓컙???곹뼢??二쇱? ?딆뒿?덈떎. snapshot???꾩쭅 鍮꾩뼱 ?덈뒗 湲곕룞 吏곹썑?먮쭔 ?먯쿇????踰? * 議고쉶??cold start 怨듬갚??硫붿썎?덈떎.
|
||||
* 활성 Tool 목록을 in-memory snapshot에서 읽습니다. 요청 경로에서는 Redis를 호출하지 않으므로 Redis 장애나 지연이 {@code tools/list} 응답 시간에 영향을 주지 않습니다. snapshot이 아직 비어 있는 기동 직후에만 원천을 한 번
|
||||
* 조회해 cold start 공백을 메웁니다.
|
||||
*/
|
||||
public List<ToolMetadata> listTools() {
|
||||
return listTools("");
|
||||
}
|
||||
|
||||
/**
|
||||
* route蹂?in-memory snapshot?먯꽌 ?쒖꽦 Tool 紐⑸줉???쎌뒿?덈떎.
|
||||
* ?붿껌 route??snapshot???놁쑝硫??대떦 route??Registry ?먯쿇????踰?議고쉶??cold start 怨듬갚??硫붿썎?덈떎.
|
||||
* route별 in-memory snapshot에서 활성 Tool 목록을 읽습니다.
|
||||
* 요청 route의 snapshot이 없으면 해당 route만 Registry 원천에서 한 번 조회해 cold start 공백을 메웁니다.
|
||||
*/
|
||||
public List<ToolMetadata> listTools(String routeKey) {
|
||||
String normalizedRouteKey = normalizeRouteKey(routeKey);
|
||||
@@ -92,34 +95,54 @@ public class ToolRegistryService {
|
||||
}
|
||||
|
||||
/**
|
||||
* ?붿껌??泥섎━?????덈뒗 Tool snapshot??memory???곸옱?먮뒗吏 諛섑솚?⑸땲?? ?먯쿇 ?먮뒗 Redis?먯꽌 ?깃났?곸쑝濡?梨꾪깮??鍮?紐⑸줉???좏슚???꾩껜 ?곹깭?대?濡?{@code null} ?щ?留??먮떒?섎ʼn, readiness ?뺤씤 怨쇱젙?먯꽌 Redis??Tool Service瑜? * ?몄텧?섏? ?딆뒿?덈떎.
|
||||
* 요청을 처리할 수 있는 Tool snapshot이 memory에 존재하는지 반환합니다. 원천 또는 Redis에서 성공적으로 채택한 값이 목록에 유효한 전체 상태이므로 {@code null} 여부만으로 판단하며, readiness 확인 과정에서 Redis나 Tool Service를
|
||||
* 호출하지 않습니다.
|
||||
*/
|
||||
public boolean hasUsableSnapshot() {
|
||||
return !snapshotsByRoute.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 湲곕룞 吏곹썑 ?ㅻⅨ replica媛 怨듭쑀 吏?먯뿉 ??ν빐 ??snapshot??癒쇱? ?곸옱?⑸땲?? 泥??먯쿇 議고쉶媛 ?앸굹湲??꾩쓽 鍮?紐⑸줉 援ш컙??以꾩씠湲??꾪븳 best-effort ?숈옉?대ʼn, ?ㅽ뙣?섍굅??媛믪씠 ?놁쑝硫??꾨Т寃껊룄 ?섏? ?딆뒿?덈떎.
|
||||
* 현재 in-memory snapshot을 확보한 route key 집합을 반환합니다.
|
||||
* 한 배포가 여러 route를 서비스하는 구성에서는 일부 route만 준비된 상태가 정상적으로 발생하므로, readiness 판정이 아니라 그 부분 상태를 관측하는 데 사용합니다.
|
||||
*/
|
||||
public Set<String> readyRouteKeys() {
|
||||
return Set.copyOf(snapshotsByRoute.keySet());
|
||||
}
|
||||
|
||||
/**
|
||||
* 원천이 선언한 route key 집합을 그대로 전달합니다.
|
||||
* {@link #readyRouteKeys()}와의 차이가 곧 "원천은 알고 있으나 아직 Tool 목록을 확보하지 못한 route"이며, 관제는 이 차이로 부분 장애를 감지합니다.
|
||||
*/
|
||||
public Set<String> knownRouteKeys() {
|
||||
return registryClient.knownRoutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* 기동 직후 다른 replica가 공유 지점에 저장해 둔 snapshot을 먼저 적재합니다. 첫 원천 조회가 끝나기 전의 빈 목록 구간을 줄이기 위한 best-effort 동작이며, 실패하거나 값이 없으면 아무것도 하지 않습니다.
|
||||
* 어느 Redis key를 읽을지는 원천이 선언한 route 목록이 정하므로, route를 모르는 시점에 호출하면 기존 단일 route key만 시도합니다.
|
||||
*/
|
||||
public void warmStartFromSharedCache() {
|
||||
if (!snapshotsByRoute.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
redisCache
|
||||
.flatMap(cache -> cache.loadSnapshot(""))
|
||||
.ifPresent(tools -> snapshotsByRoute.putIfAbsent("", List.copyOf(tools)));
|
||||
Set<String> declaredRoutes = registryClient.knownRoutes();
|
||||
Set<String> routeKeys = declaredRoutes.isEmpty() ? Set.of("") : declaredRoutes;
|
||||
routeKeys.forEach(routeKey -> redisCache
|
||||
.flatMap(cache -> cache.loadSnapshot(routeKey))
|
||||
.ifPresent(tools -> snapshotsByRoute.putIfAbsent(routeKey, List.copyOf(tools))));
|
||||
}
|
||||
|
||||
/**
|
||||
* ?쒖? Tool ?대쫫???쇱튂?섎뒗 ?쒖꽦 Tool ?섎굹瑜?李얠뒿?덈떎. cache媛 ?ㅻ옒?먯쓣 ???덉쑝誘濡?泥?議고쉶?먯꽌 紐?李얠쑝硫?Registry瑜???踰?refresh????理쒖쥌 ?먮떒?⑸땲??
|
||||
* 표준 Tool 이름과 일치하는 활성 Tool 하나를 찾습니다. cache가 오래됐을 수 있으므로 첫 조회에서 못 찾으면 Registry를 한 번 refresh한 뒤 최종 판단합니다.
|
||||
*/
|
||||
public ToolMetadata findEnabledTool(String name) {
|
||||
return findEnabledTool("", name);
|
||||
}
|
||||
|
||||
/**
|
||||
* ?붿껌 route??Tool snapshot?먯꽌 ?대쫫???쇱튂?섎뒗 ?쒖꽦 Tool ?섎굹瑜?李얠뒿?덈떎.
|
||||
* route蹂?cache媛 ?ㅻ옒?섏뿀?????덉쑝誘濡?理쒖큹 miss ???대떦 route留?refresh????理쒖쥌 ?먮떒?⑸땲??
|
||||
* 요청 route의 Tool snapshot에서 이름이 일치하는 활성 Tool 하나를 찾습니다.
|
||||
* route별 cache가 오래됐을 수 있으므로 최초 miss 시 해당 route만 refresh한 뒤 최종 판단합니다.
|
||||
*/
|
||||
public ToolMetadata findEnabledTool(String routeKey, String name) {
|
||||
String normalizedRouteKey = normalizeRouteKey(routeKey);
|
||||
@@ -143,15 +166,16 @@ public class ToolRegistryService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry ?먯쿇??吏곸젒 ?쎌뼱 ?쒖꽦 Tool snapshot??媛깆떊?⑸땲?? 議고쉶???깃났?덉쓣 ?뚮쭔 snapshot??援먯껜?섍퀬 怨듭쑀 cache????ν븯誘濡? ?ㅽ뙣媛 湲곗〈 紐⑸줉??鍮꾩슦嫄곕굹 ?ㅻⅨ replica媛 ??ν븳 ?뺤긽 snapshot????뼱?곗? ?딆뒿?덈떎. memory瑜? * 癒쇱? 媛깆떊??Redis ?μ븷? 臾닿??섍쾶 理쒖떊 ?곹깭瑜??좎??⑸땲?? ?먯쿇 議고쉶媛 ?ㅽ뙣?섎㈃ 湲곗〈 memory瑜??좎??섍퀬, memory媛 鍮꾩뼱 ?덉쓣 ?뚮쭔 怨듭쑀 cache瑜?梨꾪깮?⑸땲??
|
||||
* Registry 원천을 직접 읽어 활성 Tool snapshot을 갱신합니다. 조회에 성공했을 때만 snapshot을 교체하고 공유 cache에 저장하므로, 실패가 기존 목록을 비우거나 다른 replica가 저장한 정상 snapshot을 덮어쓰지 않습니다. memory를
|
||||
* 먼저 갱신해 Redis 장애와 무관하게 최신 상태를 유지합니다. 원천 조회가 실패하면 기존 memory를 유지하고, memory가 비어 있을 때만 공유 cache를 채택합니다.
|
||||
*/
|
||||
public List<ToolMetadata> refresh() {
|
||||
return refresh("");
|
||||
}
|
||||
|
||||
/**
|
||||
* 吏?뺥븳 route??Registry ?먯쿇??吏곸젒 ?쎌뼱 route蹂?snapshot??媛깆떊?⑸땲??
|
||||
* 媛숈? route???숈떆 refresh??single-flight濡?臾띔퀬, ?ㅻⅨ route???쒕줈 ?낅┰?곸쑝濡?媛깆떊?⑸땲??
|
||||
* 지정한 route의 Registry 원천을 직접 읽어 route별 snapshot을 갱신합니다.
|
||||
* 같은 route의 동시 refresh는 single-flight로 묶고, 다른 route는 서로 독립적으로 갱신합니다.
|
||||
*/
|
||||
public List<ToolMetadata> refresh(String routeKey) {
|
||||
String normalizedRouteKey = normalizeRouteKey(routeKey);
|
||||
@@ -174,11 +198,19 @@ public class ToolRegistryService {
|
||||
}
|
||||
|
||||
/**
|
||||
* ?꾩옱 memory???뚮젮吏?紐⑤뱺 route瑜?二쇨린?곸쑝濡?媛깆떊?⑸땲??
|
||||
* ?꾩쭅 route ?붿껌???놁쑝硫?湲곗〈 湲곕낯 route留?媛깆떊??湲곗〈 ?⑥씪 route ?숈옉???좎??⑸땲??
|
||||
* 원천이 알고 있는 모든 route를 주기적으로 갱신합니다.
|
||||
* 원천이 route 목록을 제공하면 제거 판단을 그 목록으로만 하고, route 개념이 없는 원천에서는 기존 기본 route만 갱신해 기존 단일 route 동작을 유지합니다.
|
||||
*/
|
||||
public void refreshKnownRoutes() {
|
||||
Map<String, List<ToolMetadata>> snapshots = registryClient.fetchAllTools();
|
||||
Set<String> knownRoutes = registryClient.knownRoutes();
|
||||
if (!knownRoutes.isEmpty()) {
|
||||
// 원천이 route 목록을 스스로 알고 있으면 제거 판단은 그 목록만 따른다.
|
||||
// 조회 결과를 기준으로 지우면 이번 주기에 실패한 route의 정상 snapshot까지 사라진다.
|
||||
snapshotsByRoute.keySet().removeIf(routeKey -> !knownRoutes.contains(routeKey));
|
||||
snapshots.forEach(this::replaceSnapshot);
|
||||
return;
|
||||
}
|
||||
if (!snapshots.isEmpty()) {
|
||||
snapshotsByRoute.keySet().removeIf(routeKey -> !snapshots.containsKey(routeKey));
|
||||
snapshots.forEach(this::replaceSnapshot);
|
||||
@@ -191,15 +223,15 @@ public class ToolRegistryService {
|
||||
}
|
||||
|
||||
/**
|
||||
* ?ы꽭泥섎읆 蹂꾨룄 registry瑜?媛吏??먯쿇??endpoint 紐⑸줉留?媛깆떊?⑸땲??
|
||||
* Tool manifest 議고쉶? memory snapshot 援먯껜???섑뻾?섏? ?딆쑝硫? scheduler媛 ?ы꽭 ?꾩슜 二쇨린?먯꽌 ?몄텧?⑸땲??
|
||||
* 포털처럼 별도 registry를 가진 원천의 endpoint 목록만 갱신합니다.
|
||||
* Tool manifest 조회와 memory snapshot 교체는 수행하지 않으며, scheduler가 포털 전용 주기에서 호출합니다.
|
||||
*/
|
||||
public boolean refreshSourceRegistry() {
|
||||
return registryClient.refreshSourceRegistry();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool ?먯쿇????踰?議고쉶?섍퀬 ?깃났???꾩껜 snapshot留?memory? Redis??諛섏쁺?⑸땲?? ?먯쿇 ?ㅽ뙣 ??湲곗〈 memory瑜?理쒖슦?좎쑝濡??좎??섍퀬, memory媛 鍮꾩뼱 ?덉쓣 ?뚮쭔 Redis last-good??梨꾪깮?⑸땲??
|
||||
* Tool 원천을 한 번 조회하고 성공한 전체 snapshot만 memory와 Redis에 반영합니다. 원천 실패 시 기존 memory를 최우선으로 유지하고, memory가 비어 있을 때만 Redis last-good을 채택합니다.
|
||||
*/
|
||||
private List<ToolMetadata> refreshOnce(String routeKey) {
|
||||
try {
|
||||
@@ -227,11 +259,8 @@ public class ToolRegistryService {
|
||||
}
|
||||
|
||||
/**
|
||||
* ?ㅻⅨ ?몄텧???쒖옉??refresh 寃곌낵瑜?湲곕떎由щʼn ?먮옒 RuntimeException ?좏삎??蹂댁〈?⑸땲?? ?щ윭 cache miss媛 ?숈떆??諛쒖깮?대룄 紐⑤뱺 ?몄텧?먭? 媛숈? source fetch 寃곌낵瑜??ъ슜?⑸땲??
|
||||
*/
|
||||
/**
|
||||
* ?꾩껜 registry snapshot 議고쉶 寃곌낵瑜?route蹂?memory snapshot??諛섏쁺?⑸땲??
|
||||
* ?먯쿇 議고쉶媛 ?대? ?깃났??紐⑸줉留??ㅼ뼱?ㅻ?濡??붿껌 寃쎈줈? Redis 寃쎈줈瑜?嫄대뱶由ъ? ?딄퀬, 湲곗〈 snapshot怨?鍮꾧탳??濡쒓렇? 蹂寃??대깽?몃쭔 泥섎━?⑸땲??
|
||||
* 전체 registry snapshot 조회 결과를 route별 memory snapshot에 반영합니다.
|
||||
* 원천 조회가 이미 성공한 목록만 들어오므로 요청 경로와 Redis 경로를 건드리지 않고, 기존 snapshot과 비교한 로그와 변경 이벤트만 처리합니다.
|
||||
*/
|
||||
private void replaceSnapshot(String routeKey, List<ToolMetadata> tools) {
|
||||
List<ToolMetadata> immutableTools = List.copyOf(tools);
|
||||
@@ -241,6 +270,11 @@ public class ToolRegistryService {
|
||||
redisCache.ifPresent(cache -> cache.saveSnapshot(routeKey, immutableTools));
|
||||
}
|
||||
|
||||
/**
|
||||
* 다른 호출이 이미 시작한 refresh의 결과를 기다리며 원래 RuntimeException 유형을 그대로 보존합니다.
|
||||
* 여러 cache miss가 동시에 발생해도 모든 호출자가 같은 원천 조회 한 번의 결과를 공유하도록 합니다.
|
||||
* {@link CompletionException}으로 감싸인 원인을 풀어 상위 JSON-RPC 오류 변환이 원래 예외를 보게 합니다.
|
||||
*/
|
||||
private List<ToolMetadata> awaitRefresh(CompletableFuture<List<ToolMetadata>> refresh) {
|
||||
try {
|
||||
return refresh.join();
|
||||
@@ -253,7 +287,7 @@ public class ToolRegistryService {
|
||||
}
|
||||
|
||||
/**
|
||||
* ?대쫫 議곌굔?쇰줈 ?쒖꽦 Tool ?꾨낫瑜?李얠뒿?덈떎. ?대쫫 以묐났? ?먯쿇 snapshot 蹂묓빀 ?④퀎?먯꽌 嫄곕??⑸땲??
|
||||
* 이름 조건으로 활성 Tool 후보를 찾습니다. 이름 중복은 원천 snapshot 병합 단계에서 거부합니다.
|
||||
*/
|
||||
private Optional<ToolMetadata> match(List<ToolMetadata> tools, String name) {
|
||||
return tools.stream()
|
||||
@@ -263,7 +297,7 @@ public class ToolRegistryService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 李얠? 紐삵븳 Tool ?대쫫???ы븿??Tool not found ?덉쇅瑜?留뚮벊?덈떎.
|
||||
* 찾지 못한 Tool 이름을 포함한 Tool not found 예외를 만듭니다.
|
||||
*/
|
||||
private JsonRpcException notFound(String name) {
|
||||
return new JsonRpcException(
|
||||
@@ -271,8 +305,8 @@ public class ToolRegistryService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 湲곗〈 snapshot??議댁옱?섍퀬 ??snapshot怨??ㅻ? ?뚮쭔 Tool 紐⑸줉 蹂寃??대깽?몃? 諛쒗뻾?⑸땲??
|
||||
* 理쒖큹 濡쒕뵫? Agent Builder媛 ?꾩쭅 紐⑸줉??諛쏄린 ?꾩씪 ???덉쑝誘濡??뚮┝ ??곸뿉???쒖쇅?섍퀬, ?ㅼ젣 援먯껜媛 諛쒖깮??refresh?먮쭔 ?곹뼢??以띾땲??
|
||||
* 기존 snapshot이 존재하고 새 snapshot과 다를 때만 Tool 목록 변경 이벤트를 발행합니다.
|
||||
* 최초 로딩은 Agent Builder가 아직 목록을 받기 전일 수 있으므로 알림 대상에서 제외하고, 실제 교체가 발생한 refresh에만 영향을 줍니다.
|
||||
*/
|
||||
private void publishListChangedIfNeeded(
|
||||
String routeKey, List<ToolMetadata> previous, List<ToolMetadata> current) {
|
||||
@@ -282,8 +316,8 @@ public class ToolRegistryService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 濡쒖뺄 寃利앹쓣 ?꾪빐 route蹂?in-memory snapshot??理쒖큹 ?깅줉?섍굅???ㅼ젣 蹂寃쎈맆 ?뚮쭔 INFO 濡쒓렇濡??④퉩?덈떎.
|
||||
* Portal ?먮뒗 Tool Service revision 蹂寃쎌씠 memory??諛섏쁺?섏뿀?붿? ?뺤씤?????덈룄濡?Tool metadata ?꾩껜瑜?湲곕줉?⑸땲??
|
||||
* 로컬 검증을 위해 route별 in-memory snapshot이 최초 등록되거나 실제 변경될 때만 INFO 로그로 남깁니다.
|
||||
* Portal 또는 Tool Service revision 변경이 memory에 반영되었는지 확인할 수 있도록 Tool metadata 전체를 기록합니다.
|
||||
*/
|
||||
private void logSnapshot(String routeKey, List<ToolMetadata> previous, List<ToolMetadata> current) {
|
||||
boolean changed = previous == null || !previous.equals(current);
|
||||
@@ -296,8 +330,8 @@ public class ToolRegistryService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 寃利?濡쒓렇???ъ슜??route蹂?snapshot ?댁슜??JSON 臾몄옄?대줈 蹂?섑빀?덈떎.
|
||||
* 吏곷젹???ㅽ뙣媛 refresh ?깃났 ?щ????곹뼢??二쇱? ?딅룄濡??ㅽ뙣 ??理쒖냼 臾몄옄???쒗쁽?쇰줈 ?泥댄빀?덈떎.
|
||||
* 검증 로그에 사용할 route별 snapshot 내용을 JSON 문자열로 변환합니다.
|
||||
* 직렬화 실패가 refresh 성공 여부에 영향을 주지 않도록 실패 시 최소 문자열 표현으로 대체합니다.
|
||||
*/
|
||||
private String snapshotJson(String routeKey, List<ToolMetadata> current) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
@@ -313,7 +347,7 @@ public class ToolRegistryService {
|
||||
}
|
||||
|
||||
/**
|
||||
* route key??null怨?怨듬갚??湲곗〈 ?⑥씪 snapshot key??鍮?臾몄옄?대줈 ?뺢퇋?뷀빀?덈떎.
|
||||
* route key의 null과 공백을 기존 단일 snapshot key인 빈 문자열로 정규화합니다.
|
||||
*/
|
||||
private String normalizeRouteKey(String routeKey) {
|
||||
return routeKey == null ? "" : routeKey.trim();
|
||||
|
||||
Reference in New Issue
Block a user