cbco = cbr.getConfiguration(CB_CONFIG_NAME);
78 | if (!cbco.isPresent()) {
79 | cbc = builtCBC;
80 | cbr.addConfiguration(CB_CONFIG_NAME, cbc);
81 | }
82 | return cbr;
83 | }
84 |
85 | @Bean
86 | @Lazy
87 | public CircuitBreakerConfig defaultCircuitBreakerConfig() {
88 | CircuitBreakerConfig cbc = CircuitBreakerConfig.custom()
89 | .failureRateThreshold(CB_FAILURE_RATE_THRESHOLD)
90 | .slowCallRateThreshold(CB_SLOW_RATE_THRESHOLD)
91 | .waitDurationInOpenState(CB_WAIT_DURATION_OPEN_STATE)
92 | .slowCallDurationThreshold(CB_SLOW_CALL_DURATION)
93 | .permittedNumberOfCallsInHalfOpenState(CB_NUM_CALLS_HALF_OPEN_STATE)
94 | .minimumNumberOfCalls(CB_MIN_NUM_CALLS)
95 | .slidingWindowType(SlidingWindowType.TIME_BASED)
96 | .slidingWindowSize(CB_SLIDING_WINDOW_SIZE)
97 | .build();
98 | return cbc;
99 | }
100 | ```
101 |
--------------------------------------------------------------------------------
/src/main/java/com/ibm/cloud/cache/redis/CircuitBreakerRedisCache.java:
--------------------------------------------------------------------------------
1 | package com.ibm.cloud.cache.redis;
2 |
3 | import java.util.concurrent.Callable;
4 | import java.util.logging.Level;
5 | import java.util.logging.Logger;
6 |
7 | import org.springframework.context.ApplicationContext;
8 | import org.springframework.data.redis.cache.RedisCache;
9 |
10 | import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
11 | import io.github.resilience4j.circuitbreaker.CircuitBreaker;
12 | import io.github.resilience4j.circuitbreaker.CircuitBreaker.Metrics;
13 | import io.vavr.CheckedFunction0;
14 | import io.vavr.control.Try;
15 |
16 | /**
17 | * RedisCache implementation that uses a resilience4j Circuit Breaker to bypass the Redis instance calls if the circuit
18 | * is open. This allows us to not be sensitive to Redis instabilities. If the call to Redis fails, Spring Cache
19 | * will treat it as a cache miss and continue executing the cached method normally.
20 | */
21 | public class CircuitBreakerRedisCache extends RedisCache {
22 |
23 | private static final Logger logger = Logger.getLogger(CircuitBreakerRedisCache.class.getName());
24 | private static final Level loggerLevel = Level.FINER;
25 |
26 | private RedisCache redisCache;
27 | private CircuitBreaker circuitBreaker;
28 |
29 | /**
30 | *
31 | * @param redisCache The RedisCache instance to be wrapped.
32 | */
33 | public CircuitBreakerRedisCache(RedisCache redisCache) {
34 | /*
35 | * this class is implemented using both composition and inheritance due to limitations in RedisCache that prevent
36 | * properly extending it. if this PR is approved, composition is no longer needed: https://github.com/spring-projects/spring-data-redis/pull/500
37 | */
38 | super(redisCache.getName(), redisCache.getNativeCache(), redisCache.getCacheConfiguration());
39 | this.redisCache = redisCache;
40 | ApplicationContext appCtx = ApplicationContextHolder.getContext();
41 | if (appCtx != null) {
42 | /*
43 | * using this bean retrieval instead of @Autowired due to the RedisCache field which needs to be passed and can't
44 | * be instantiated directly.
45 | *
46 | * this call can be changed to specify a bean qualifier name in cases where multiple circuit breakers are used.
47 | */
48 | this.circuitBreaker = appCtx.getBean(CircuitBreaker.class);
49 | }
50 | }
51 |
52 | /**
53 | * Used by unit tests.
54 | * @param redisCache
55 | * @param cb
56 | */
57 | public CircuitBreakerRedisCache(RedisCache redisCache, CircuitBreaker cb) {
58 | this(redisCache);
59 | this.circuitBreaker = cb;
60 | }
61 |
62 | @Override
63 | public ValueWrapper get(Object key) {
64 | this.logCBMetrics();
65 | CheckedFunction0 checkedFunction = this.circuitBreaker.decorateCheckedSupplier(() -> this.redisCache.get(key));
66 | Try result = Try.of(checkedFunction).recoverWith(CallNotPermittedException.class, e -> this.recover(e));
67 | return result.get();
68 | }
69 |
70 | /**
71 | * Logs a portion of the Circuit Breaker's metrics at a predefined logger level.
72 | */
73 | protected void logCBMetrics() {
74 | logCBMetrics(this.circuitBreaker, loggerLevel);
75 | }
76 |
77 | /**
78 | * Subclasses can override to define new behavior when the circuit is open.
79 | *
80 | * @param The return type.
81 | * @param e A CallNotPermittedException exception indicating the call was not allowed because the circuit is open.
82 | *
83 | * @return A Success with null return value.
84 | */
85 | protected Try recover(CallNotPermittedException e) {
86 | logger.log(loggerLevel, "Circuit broken, cache bypassed: " + e.getMessage());
87 | return Try.success(null);
88 | }
89 |
90 | /**
91 | * Subclasses can override to define new behavior when the circuit is open.
92 | *
93 | * @param e A CallNotPermittedException exception indicating the call was not allowed because the circuit is open.
94 | *
95 | * @return null
96 | */
97 | protected Void recoverVoid(CallNotPermittedException e) {
98 | logger.log(loggerLevel, "Circuit broken, cache bypassed: " + e.getMessage());
99 | return null;
100 | }
101 |
102 | @Override
103 | public T get(Object key, Callable valueLoader) {
104 | this.logCBMetrics();
105 | CheckedFunction0 checkedFunction = this.circuitBreaker.decorateCheckedSupplier(() -> this.redisCache.get(key, valueLoader));
106 | Try result = Try.of(checkedFunction).recoverWith(CallNotPermittedException.class, e -> this.recover(e));
107 | if (result.isFailure()) {
108 | Throwable cause = result.getCause();
109 | logger.log(loggerLevel, "Failure from RedisCache.get(): " + cause.getMessage());
110 | }
111 | return result.get();
112 | }
113 |
114 | @Override
115 | public T get(Object key, Class type) {
116 | this.logCBMetrics();
117 | CheckedFunction0 checkedFunction = this.circuitBreaker.decorateCheckedSupplier(() -> this.redisCache.get(key, type));
118 | Try result = Try.of(checkedFunction).recoverWith(CallNotPermittedException.class, e -> this.recover(e));
119 | if (result.isFailure()) {
120 | Throwable cause = result.getCause();
121 | logger.log(loggerLevel, "Failure from RedisCache.get(): " + cause.getMessage());
122 | }
123 | return result.get();
124 | }
125 |
126 | @Override
127 | public void put(Object key, Object value) {
128 | this.logCBMetrics();
129 | Runnable decorateRunnable = this.circuitBreaker.decorateRunnable(() -> this.redisCache.put(key, value));
130 | Try result = Try.runRunnable(decorateRunnable).recover(CallNotPermittedException.class, e -> this.recoverVoid(e));
131 | if (result.isFailure()) {
132 | Throwable cause = result.getCause();
133 | logger.log(loggerLevel, "Failure from RedisCache.put(): " + cause.getMessage());
134 | }
135 | result.get();
136 | }
137 |
138 | @Override
139 | public ValueWrapper putIfAbsent(Object key, Object value) {
140 | this.logCBMetrics();
141 | CheckedFunction0 checkedFunction = this.circuitBreaker.decorateCheckedSupplier(() -> this.redisCache.putIfAbsent(key, value));
142 | Try result = Try.of(checkedFunction).recoverWith(CallNotPermittedException.class, e -> this.recover(e));
143 | if (result.isFailure()) {
144 | Throwable cause = result.getCause();
145 | logger.log(loggerLevel, "Failure from RedisCache.putIfAbsent(): " + cause.getMessage());
146 | }
147 | return result.get();
148 | }
149 |
150 | @Override
151 | public void evict(Object key) {
152 | this.logCBMetrics();
153 | Runnable decorateRunnable = this.circuitBreaker.decorateRunnable(() -> this.redisCache.evict(key));
154 | Try result = Try.runRunnable(decorateRunnable).recover(CallNotPermittedException.class, e -> this.recoverVoid(e));
155 | if (result.isFailure()) {
156 | Throwable cause = result.getCause();
157 | logger.log(loggerLevel, "Failure from RedisCache.evict(): " + cause.getMessage());
158 | }
159 | result.get();
160 | }
161 |
162 | @Override
163 | public void clear() {
164 | this.logCBMetrics();
165 | Runnable decorateRunnable = this.circuitBreaker.decorateRunnable(() -> this.redisCache.clear());
166 | Try result = Try.runRunnable(decorateRunnable).recover(CallNotPermittedException.class, e -> this.recoverVoid(e));
167 | if (result.isFailure()) {
168 | Throwable cause = result.getCause();
169 | logger.log(loggerLevel, "Failure from RedisCache.clear(): " + cause.getMessage());
170 | }
171 | result.get();
172 | }
173 |
174 | public static void logCBMetrics(CircuitBreaker cb, Level loggerLevel) {
175 | Metrics cbMetrics = cb.getMetrics();
176 | StringBuilder sb = new StringBuilder("CircuitBreaker Metrics for " + cb + ":\n");
177 | sb.append("\tNum successful calls: ").append(cbMetrics.getNumberOfSuccessfulCalls()).append("\n");
178 | sb.append("\tNum failed calls: ").append(cbMetrics.getNumberOfFailedCalls()).append("\n");
179 | sb.append("\tNum slow calls: ").append(cbMetrics.getNumberOfSlowCalls()).append("\n");
180 | sb.append("\tSlow call rate: ").append(cbMetrics.getSlowCallRate()).append("\n");
181 | sb.append("\tNum not permitted calls: ").append(cbMetrics.getNumberOfNotPermittedCalls()).append("\n");
182 | logger.log(loggerLevel, sb.toString());
183 | }
184 |
185 | }
186 |
--------------------------------------------------------------------------------
/src/test/java/com/ibm/cloud/cache/redis/CircuitBreakerRedisCacheTest.java:
--------------------------------------------------------------------------------
1 | package com.ibm.cloud.cache.redis;
2 |
3 |
4 | import static org.junit.Assert.assertEquals;
5 | import static org.junit.Assert.assertNotNull;
6 | import static org.junit.Assert.assertNull;
7 | import static org.junit.Assert.fail;
8 |
9 | import java.io.IOException;
10 | import java.nio.ByteBuffer;
11 | import java.time.Duration;
12 | import java.util.Optional;
13 | import java.util.logging.Level;
14 | import java.util.logging.Logger;
15 |
16 | import org.junit.Before;
17 | import org.junit.Test;
18 | import org.mockito.Mock;
19 | import org.mockito.Mockito;
20 | import org.mockito.MockitoAnnotations;
21 | import org.mockito.invocation.InvocationOnMock;
22 | import org.mockito.stubbing.Answer;
23 | import org.springframework.cache.Cache.ValueWrapper;
24 | import org.springframework.cache.support.SimpleValueWrapper;
25 | import org.springframework.core.convert.ConversionService;
26 | import org.springframework.core.convert.TypeDescriptor;
27 | import org.springframework.data.redis.cache.RedisCache;
28 | import org.springframework.data.redis.cache.RedisCacheConfiguration;
29 | import org.springframework.data.redis.cache.RedisCacheWriter;
30 | import org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair;
31 |
32 | import io.github.resilience4j.circuitbreaker.CircuitBreaker;
33 | import io.github.resilience4j.circuitbreaker.CircuitBreaker.State;
34 | import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
35 | import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig.SlidingWindowType;
36 | import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
37 | import io.github.resilience4j.circuitbreaker.event.CircuitBreakerOnStateTransitionEvent;
38 |
39 | public class CircuitBreakerRedisCacheTest {
40 |
41 | private static final Logger logger = Logger.getLogger(CircuitBreakerRedisCacheTest.class.getName());
42 |
43 | private static final String TEST_KEY = "test";
44 |
45 | public static final String CB_NAME = "testRedisCB";
46 | public static final String CB_CONFIG_NAME = "testRedisCBConfig";
47 | public static final int CB_FAILURE_RATE_THRESHOLD = 100;
48 | public static final int CB_SLIDING_WINDOW_SIZE = 6;
49 | public static final int CB_MIN_NUM_CALLS = 6;
50 | public static final Duration CB_WAIT_DURATION_OPEN_STATE = Duration.ofSeconds(3);
51 | public static final int CB_NUM_CALLS_HALF_OPEN_STATE = 6;
52 | public static final Duration CB_SLOW_CALL_DURATION = Duration.ofMillis(1500); // 1.5 secs
53 | public static final int CB_SLOW_RATE_THRESHOLD = 50;
54 |
55 | @Mock
56 | private RedisCacheWriter cacheWriter;
57 | @Mock
58 | private RedisCacheConfiguration redisCacheConfig;
59 | @Mock
60 | private ConversionService conversionService;
61 | @Mock
62 | private SerializationPair keySerializationPair;
63 | @Mock
64 | private SerializationPair