diff --git a/src/main/java/com/eactive/eai/agent/command/ReloadInflowClientControlCommand.java b/src/main/java/com/eactive/eai/agent/command/ReloadInflowClientControlCommand.java new file mode 100644 index 0000000..8333557 --- /dev/null +++ b/src/main/java/com/eactive/eai/agent/command/ReloadInflowClientControlCommand.java @@ -0,0 +1,58 @@ +package com.eactive.eai.agent.command; + +import com.eactive.eai.common.inflow.AbstractInflowControlManager; +import com.eactive.eai.common.inflow.InflowControlUtil; +import com.eactive.eai.common.util.Logger; +import com.eactive.eai.custom.inflow.ClientDualInflowControlManager; + +public class ReloadInflowClientControlCommand extends Command { + + private static final long serialVersionUID = 1L; + + public ReloadInflowClientControlCommand() { + super("ReloadInflowClientControlCommand"); + } + + public Object execute() throws CommandException { + Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT); + if (logger.isInfo()) + logger.info(this.name + " is executed"); + + if (!(args instanceof String)) { + String rspErrorCode = "RECEAIMCM001"; + String msg = makeException(rspErrorCode, null); + if (logger.isError()) + logger.error(msg); + throw new CommandException(msg); + } + + String keyName = (String) args; + try { + AbstractInflowControlManager base = InflowControlUtil.getInflowControlManager(); + if (!(base instanceof ClientDualInflowControlManager)) { + throw new IllegalStateException("ClientDualInflowControlManager 가 활성화되지 않았습니다."); + } + ClientDualInflowControlManager manager = (ClientDualInflowControlManager) base; + + if (keyName != null) { + if ("ALL".equals(keyName)) { + manager.reloadClient(); + if (logger.isWarn()) + logger.warn(this.name + "] all rule Reload."); + } else { + manager.reloadClient(keyName); + if (logger.isWarn()) + logger.warn(this.name + "] " + keyName + " Reload."); + } + } + + } catch (Exception e) { + String rspErrorCode = "RECEAIMCM002"; + String msg = makeException(rspErrorCode, e); + if (logger.isError()) + logger.error(msg, e); + throw new CommandException(msg); + } + return "success"; + } +} diff --git a/src/main/java/com/eactive/eai/agent/command/RemoveInflowClientControlCommand.java b/src/main/java/com/eactive/eai/agent/command/RemoveInflowClientControlCommand.java new file mode 100644 index 0000000..3750073 --- /dev/null +++ b/src/main/java/com/eactive/eai/agent/command/RemoveInflowClientControlCommand.java @@ -0,0 +1,48 @@ +package com.eactive.eai.agent.command; + +import com.eactive.eai.common.inflow.AbstractInflowControlManager; +import com.eactive.eai.common.inflow.InflowControlUtil; +import com.eactive.eai.common.util.Logger; +import com.eactive.eai.custom.inflow.ClientDualInflowControlManager; + +public class RemoveInflowClientControlCommand extends Command { + + private static final long serialVersionUID = 1L; + + public RemoveInflowClientControlCommand() { + super("RemoveInflowClientControlCommand"); + } + + public Object execute() throws CommandException { + Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT); + if (logger.isInfo()) + logger.info(this.name + " is executed"); + + if (!(args instanceof String)) { + String rspErrorCode = "RECEAIMCM001"; + String msg = makeException(rspErrorCode, null); + if (logger.isError()) + logger.error(msg); + throw new CommandException(msg); + } + + String key = (String) args; + try { + AbstractInflowControlManager base = InflowControlUtil.getInflowControlManager(); + if (!(base instanceof ClientDualInflowControlManager)) { + throw new IllegalStateException("ClientDualInflowControlManager 가 활성화되지 않았습니다."); + } + ClientDualInflowControlManager manager = (ClientDualInflowControlManager) base; + manager.removeClient(key); + if (logger.isWarn()) + logger.warn(this.name + "] " + key + " removed."); + } catch (Exception e) { + String rspErrorCode = "RECEAIMCM002"; + String msg = makeException(rspErrorCode, e); + if (logger.isError()) + logger.error(msg, e); + throw new CommandException(msg); + } + return "success"; + } +} diff --git a/src/main/java/com/eactive/eai/custom/inbound/processor/DJBRequestProcessor.java b/src/main/java/com/eactive/eai/custom/inbound/processor/DJBRequestProcessor.java index d2f8759..8e7c133 100644 --- a/src/main/java/com/eactive/eai/custom/inbound/processor/DJBRequestProcessor.java +++ b/src/main/java/com/eactive/eai/custom/inbound/processor/DJBRequestProcessor.java @@ -4,9 +4,9 @@ import org.apache.commons.lang3.StringUtils; import com.eactive.eai.common.inflow.Bucket; import com.eactive.eai.common.inflow.InflowTargetVO; -import com.eactive.eai.common.inflow.dual.ClientDualBucket; import com.eactive.eai.common.inflow.dual.DualBucket; import com.eactive.eai.common.inflow.dual.DualCustomBucket; +import com.eactive.eai.custom.inflow.ClientDualBucket; import com.eactive.eai.inbound.processor.RequestProcessor; /** diff --git a/src/main/java/com/eactive/eai/custom/inflow/ClientDualBucket.java b/src/main/java/com/eactive/eai/custom/inflow/ClientDualBucket.java new file mode 100644 index 0000000..07a84de --- /dev/null +++ b/src/main/java/com/eactive/eai/custom/inflow/ClientDualBucket.java @@ -0,0 +1,23 @@ +package com.eactive.eai.custom.inflow; + +import com.eactive.eai.common.inflow.InflowTargetVO; +import com.eactive.eai.common.inflow.dual.DualBucket; + +/** + * DualBucket에 클라이언트 유량제어 메서드를 추가한 인터페이스. + * {@link ClientDualInflowControlManager}가 구현한다. + */ +public interface ClientDualBucket extends DualBucket { + + /** + * 클라이언트 유량제어 체크 — 차단 원인 포함. + * @return null이면 통과, "PER_SECOND" 또는 "THRESHOLD"이면 해당 버킷에서 차단 + */ + String isClientPassDetail(String clientId); + + /** + * 클라이언트 유량제어 설정 조회 — 에러 메시지 생성용. + * @return 등록된 설정이 없으면 null + */ + InflowTargetVO getClientInflowThreshold(String clientId); +} diff --git a/src/main/java/com/eactive/eai/custom/inflow/ClientDualInflowControlManager.java b/src/main/java/com/eactive/eai/custom/inflow/ClientDualInflowControlManager.java new file mode 100644 index 0000000..4d45ee1 --- /dev/null +++ b/src/main/java/com/eactive/eai/custom/inflow/ClientDualInflowControlManager.java @@ -0,0 +1,114 @@ +package com.eactive.eai.custom.inflow; + +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.stereotype.Component; + +import com.eactive.eai.common.inflow.Bucket; +import com.eactive.eai.common.inflow.InflowTargetVO; +import com.eactive.eai.common.inflow.InflowType; +import com.eactive.eai.common.inflow.dual.DualCustomBucket; +import com.eactive.eai.common.inflow.dual.DualInflowControlManager; +import com.eactive.eai.common.lifecycle.LifecycleException; +import com.eactive.eai.common.util.ApplicationContextProvider; +import com.eactive.eai.common.util.Logger; + +/** + * 어댑터/인터페이스 이중 버킷(DualInflowControlManager)에 클라이언트 유량제어를 추가한 관리자. + * + *

적용 방법: INFLOW.properties에 아래 한 줄 추가. + *

inflow.control.bucket.className=com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager
+ */ +@Component +public class ClientDualInflowControlManager extends DualInflowControlManager implements ClientDualBucket { + + private static final Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT); + + public static synchronized Bucket getBucket() { + return ApplicationContextProvider.getContext().getBean(ClientDualInflowControlManager.class); + } + + private volatile Map clientBucketMap = new ConcurrentHashMap<>(); + private final ConcurrentHashMap clientMetricsMap = new ConcurrentHashMap<>(); + + // ------------------------------------------------------------------------- + // Lifecycle + // ------------------------------------------------------------------------- + + @Override + public void start() throws LifecycleException { + super.start(); + try { + initClients(); + } catch (Exception e) { + throw new LifecycleException("ClientDualInflowControlManager init failed: " + e.getMessage()); + } + } + + // ------------------------------------------------------------------------- + // 초기화 / 리로드 + // ------------------------------------------------------------------------- + + private void initClients() throws Exception { + this.clientBucketMap = loadBucketMap(InflowType.CLIENT); + if (logger.isInfo()) logger.info("ClientDualInflowControlManager] initClients: " + clientBucketMap.size() + " buckets"); + } + + public synchronized void reloadClient() throws Exception { + initClients(); + clientMetricsMap.clear(); + } + + public synchronized void reloadClient(String clientId) throws Exception { + reloadBucketMap(clientBucketMap, InflowType.CLIENT, clientId); + TargetMetrics m = clientMetricsMap.get(clientId); + if (m != null) m.reset(); + } + + public synchronized void removeClient(String clientId) { + clientBucketMap.remove(clientId); + clientMetricsMap.remove(clientId); + } + + // ------------------------------------------------------------------------- + // 클라이언트 메트릭 접근자 + // ------------------------------------------------------------------------- + + public TargetMetrics getClientMetrics(String clientId) { return clientMetricsMap.get(clientId); } + public Map getAllClientMetrics() { return Collections.unmodifiableMap(clientMetricsMap); } + public void resetClientMetrics(String clientId) { TargetMetrics m = clientMetricsMap.get(clientId); if (m != null) m.reset(); } + public void resetAllClientMetrics() { clientMetricsMap.values().forEach(TargetMetrics::reset); } + + // ------------------------------------------------------------------------- + // ClientDualBucket — 클라이언트 유량제어 + // ------------------------------------------------------------------------- + + @Override + public String isClientPassDetail(String clientId) { + DualCustomBucket b = clientBucketMap.get(clientId); + if (b == null) return DualCustomBucket.RESULT_PASS; + String result = b.tryConsume(); + recordMetrics(clientMetricsMap, clientId, result); + return result; + } + + @Override + public InflowTargetVO getClientInflowThreshold(String clientId) { + DualCustomBucket b = clientBucketMap.get(clientId); + return (b == null) ? null : b.getInflowTargetVo(); + } + + // ------------------------------------------------------------------------- + // 모니터링용 접근자 + // ------------------------------------------------------------------------- + + public DualCustomBucket getClientBucket(String clientId) { + return clientBucketMap.get(clientId); + } + + public Map getClientBucketMap() { + return clientBucketMap; + } +} diff --git a/src/main/java/com/eactive/eai/custom/inflow/ClientInflowTargetBucketService.java b/src/main/java/com/eactive/eai/custom/inflow/ClientInflowTargetBucketService.java index cb5e388..51c69b5 100644 --- a/src/main/java/com/eactive/eai/custom/inflow/ClientInflowTargetBucketService.java +++ b/src/main/java/com/eactive/eai/custom/inflow/ClientInflowTargetBucketService.java @@ -9,7 +9,6 @@ import org.springframework.stereotype.Service; import com.eactive.eai.common.inflow.AbstractInflowControlManager; import com.eactive.eai.common.inflow.InflowControlUtil; import com.eactive.eai.common.inflow.InflowTargetVO; -import com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager; import com.eactive.eai.common.inflow.dual.DualCustomBucket; import com.eactive.eai.manage.inflow.GroupBucketStatusDTO; import com.eactive.eai.manage.inflow.TargetBucketStatusDTO; diff --git a/src/main/java/com/eactive/eai/custom/inflow/ClientInflowTargetMetricService.java b/src/main/java/com/eactive/eai/custom/inflow/ClientInflowTargetMetricService.java index 39df98e..cb92dfc 100644 --- a/src/main/java/com/eactive/eai/custom/inflow/ClientInflowTargetMetricService.java +++ b/src/main/java/com/eactive/eai/custom/inflow/ClientInflowTargetMetricService.java @@ -8,7 +8,6 @@ import org.springframework.stereotype.Service; import com.eactive.eai.common.inflow.AbstractInflowControlManager; import com.eactive.eai.common.inflow.InflowControlUtil; import com.eactive.eai.common.inflow.InflowTargetVO; -import com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager; import com.eactive.eai.common.inflow.dual.DualInflowControlManager; import com.eactive.eai.manage.inflow.TargetMetricDTO; diff --git a/src/test/java/com/eactive/eai/custom/inbound/processor/ClientDualInflowControlManagerMetricsTest.java b/src/test/java/com/eactive/eai/custom/inbound/processor/ClientDualInflowControlManagerMetricsTest.java new file mode 100644 index 0000000..58e6b2d --- /dev/null +++ b/src/test/java/com/eactive/eai/custom/inbound/processor/ClientDualInflowControlManagerMetricsTest.java @@ -0,0 +1,196 @@ +package com.eactive.eai.custom.inbound.processor; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import com.eactive.eai.common.inflow.InflowTargetVO; +import com.eactive.eai.common.inflow.dual.DualCustomBucket; +import com.eactive.eai.common.inflow.dual.DualInflowControlManager; +import com.eactive.eai.custom.inflow.ClientDualInflowControlManager; + +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket4j; +import io.github.bucket4j.local.LocalBucket; + +/** + * ClientDualInflowControlManager 클라이언트 메트릭 단위 테스트. + */ +class ClientDualInflowControlManagerMetricsTest { + + private ClientDualInflowControlManager manager; + + @BeforeEach + void setUp() { + manager = new ClientDualInflowControlManager(); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private InflowTargetVO makeTargetVO(String name) { + InflowTargetVO vo = new InflowTargetVO(); + vo.setName(name); + vo.setThresholdPerSecond(100); + vo.setThreshold(10000); + vo.setThresholdTimeUnit("DAY"); + vo.setActivate(true); + return vo; + } + + private LocalBucket stableBucket(long capacity) { + return Bucket4j.builder() + .addLimit(Bandwidth.simple(capacity, Duration.ofHours(1))) + .build(); + } + + private LocalBucket exhaustedBucket(long capacity) { + LocalBucket b = stableBucket(capacity); + b.tryConsume(capacity); + return b; + } + + private DualCustomBucket passDualBucket(String name) { + return new DualCustomBucket(stableBucket(100), null, makeTargetVO(name)); + } + + private DualCustomBucket blockedPerSecondDualBucket(String name) { + return new DualCustomBucket(exhaustedBucket(1), null, makeTargetVO(name)); + } + + private DualCustomBucket blockedThresholdDualBucket(String name) { + return new DualCustomBucket(stableBucket(100), exhaustedBucket(1), makeTargetVO(name)); + } + + private void injectClientMap(Map map) { + ReflectionTestUtils.setField(manager, "clientBucketMap", map); + } + + // ========================================================================= + // isClientPassDetail() — 카운터 증가 + // ========================================================================= + + @Test + void isClientPassDetail_bucketNotRegistered_noMetricsCreated() { + manager.isClientPassDetail("UNKNOWN"); + + assertNull(manager.getClientMetrics("UNKNOWN")); + } + + @Test + void isClientPassDetail_pass_incrementsAllowed() { + Map map = new ConcurrentHashMap<>(); + map.put("C1", passDualBucket("C1")); + injectClientMap(map); + + manager.isClientPassDetail("C1"); + + assertEquals(1, manager.getClientMetrics("C1").allowed.get()); + } + + @Test + void isClientPassDetail_blockedThreshold_incrementsRejectedThreshold() { + Map map = new ConcurrentHashMap<>(); + map.put("C1", blockedThresholdDualBucket("C1")); + injectClientMap(map); + + manager.isClientPassDetail("C1"); + + assertEquals(1, manager.getClientMetrics("C1").rejectedThreshold.get()); + } + + @Test + void isClientPassDetail_blockedPerSecond_incrementsRejectedPerSecond() { + Map map = new ConcurrentHashMap<>(); + map.put("C1", blockedPerSecondDualBucket("C1")); + injectClientMap(map); + + manager.isClientPassDetail("C1"); + + assertEquals(1, manager.getClientMetrics("C1").rejectedPerSecond.get()); + } + + // ========================================================================= + // getAllClientMetrics — 조회 및 불변성 + // ========================================================================= + + @Test + void getAllClientMetrics_empty_returnsEmptyMap() { + assertTrue(manager.getAllClientMetrics().isEmpty()); + } + + @Test + void getAllClientMetrics_afterCalls_containsEntries() { + Map map = new ConcurrentHashMap<>(); + map.put("C1", passDualBucket("C1")); + map.put("C2", passDualBucket("C2")); + injectClientMap(map); + + manager.isClientPassDetail("C1"); + manager.isClientPassDetail("C2"); + + Map all = manager.getAllClientMetrics(); + assertEquals(2, all.size()); + assertTrue(all.containsKey("C1")); + assertTrue(all.containsKey("C2")); + } + + // ========================================================================= + // resetClientMetrics / resetAllClientMetrics + // ========================================================================= + + @Test + void resetClientMetrics_resetsTargetClient() { + Map map = new ConcurrentHashMap<>(); + map.put("C1", passDualBucket("C1")); + injectClientMap(map); + + manager.isClientPassDetail("C1"); + manager.resetClientMetrics("C1"); + + assertEquals(0, manager.getClientMetrics("C1").allowed.get()); + } + + @Test + void resetAllClientMetrics_resetsAll() { + Map map = new ConcurrentHashMap<>(); + map.put("C1", passDualBucket("C1")); + map.put("C2", passDualBucket("C2")); + injectClientMap(map); + + manager.isClientPassDetail("C1"); + manager.isClientPassDetail("C2"); + manager.resetAllClientMetrics(); + + assertEquals(0, manager.getClientMetrics("C1").allowed.get()); + assertEquals(0, manager.getClientMetrics("C2").allowed.get()); + } + + // ========================================================================= + // removeClient — 메트릭 자동 삭제 + // ========================================================================= + + @Test + void removeClient_cleansUpMetrics() { + Map map = new ConcurrentHashMap<>(); + map.put("C1", passDualBucket("C1")); + injectClientMap(map); + + manager.isClientPassDetail("C1"); + assertNotNull(manager.getClientMetrics("C1")); + + manager.removeClient("C1"); + + assertNull(manager.getClientMetrics("C1")); + } +} diff --git a/src/test/java/com/eactive/eai/custom/inbound/processor/DJBRequestProcessorTest.java b/src/test/java/com/eactive/eai/custom/inbound/processor/DJBRequestProcessorTest.java index 9b796b6..84cf054 100644 --- a/src/test/java/com/eactive/eai/custom/inbound/processor/DJBRequestProcessorTest.java +++ b/src/test/java/com/eactive/eai/custom/inbound/processor/DJBRequestProcessorTest.java @@ -1,7 +1,12 @@ package com.eactive.eai.custom.inbound.processor; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -10,11 +15,11 @@ import org.springframework.test.util.ReflectionTestUtils; import com.eactive.eai.common.inflow.Bucket; import com.eactive.eai.common.inflow.InflowTargetVO; -import com.eactive.eai.common.inflow.dual.ClientDualBucket; import com.eactive.eai.common.inflow.dual.DualBucket; import com.eactive.eai.common.inflow.dual.DualCustomBucket; import com.eactive.eai.common.server.EAIServerManager; import com.eactive.eai.common.util.ApplicationContextProvider; +import com.eactive.eai.custom.inflow.ClientDualBucket; /** * DJBRequestProcessor 단위 테스트. diff --git a/src/test/java/com/eactive/eai/custom/inflow/ClientInflowTargetBucketServiceTest.java b/src/test/java/com/eactive/eai/custom/inflow/ClientInflowTargetBucketServiceTest.java index d40cbe1..57be10b 100644 --- a/src/test/java/com/eactive/eai/custom/inflow/ClientInflowTargetBucketServiceTest.java +++ b/src/test/java/com/eactive/eai/custom/inflow/ClientInflowTargetBucketServiceTest.java @@ -1,7 +1,11 @@ package com.eactive.eai.custom.inflow; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import java.time.Duration; import java.util.List; @@ -11,7 +15,6 @@ import java.util.concurrent.ConcurrentHashMap; import org.junit.jupiter.api.Test; import com.eactive.eai.common.inflow.InflowTargetVO; -import com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager; import com.eactive.eai.common.inflow.dual.DualCustomBucket; import com.eactive.eai.manage.inflow.GroupBucketStatusDTO; import com.eactive.eai.manage.inflow.TargetBucketStatusDTO; diff --git a/src/test/java/com/eactive/eai/custom/inflow/ClientInflowTargetMetricServiceTest.java b/src/test/java/com/eactive/eai/custom/inflow/ClientInflowTargetMetricServiceTest.java index 5e9325b..4289331 100644 --- a/src/test/java/com/eactive/eai/custom/inflow/ClientInflowTargetMetricServiceTest.java +++ b/src/test/java/com/eactive/eai/custom/inflow/ClientInflowTargetMetricServiceTest.java @@ -1,14 +1,22 @@ package com.eactive.eai.custom.inflow; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.util.List; import org.junit.jupiter.api.Test; import com.eactive.eai.common.inflow.InflowTargetVO; -import com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager; import com.eactive.eai.common.inflow.dual.DualInflowControlManager; import com.eactive.eai.manage.inflow.TargetMetricDTO;