Skip to content

Commit 574d531

Browse files
authored
Merge branch 'dev' into LEGLINK-790
2 parents cc352e8 + 9348f69 commit 574d531

2 files changed

Lines changed: 212 additions & 2 deletions

File tree

Java/validation/src/main/java/com/lantanagroup/link/validation/providers/ValidationCacheService.java

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ public class ValidationCacheService {
1414
*/
1515
@Cacheable(
1616
value = "validateCodeCache",
17-
key = "T(java.util.Objects).hash(#codeSystem, #code, #display, #valueSetUrl)",
17+
key = "#root.target.validateCodeCacheKey(#codeSystem, #code, #display, #valueSetUrl)",
1818
unless = "#result == null"
1919
)
2020
public IValidationSupport.CodeValidationResult cachedValidateCode(
@@ -27,6 +27,22 @@ public IValidationSupport.CodeValidationResult cachedValidateCode(
2727
return delegate.invokeRemoteValidateCode(codeSystem, code, display, valueSetUrl, (IBaseResource) null);
2828
}
2929

30+
public String validateCodeCacheKey(
31+
String codeSystem,
32+
String code,
33+
String display,
34+
String valueSetUrl
35+
) {
36+
return encodeKeyComponent(codeSystem)
37+
+ encodeKeyComponent(code)
38+
+ encodeKeyComponent(display)
39+
+ encodeKeyComponent(valueSetUrl);
40+
}
41+
42+
private static String encodeKeyComponent(String value) {
43+
return value == null ? "N;" : "V" + value.length() + ":" + value;
44+
}
45+
3046
/**
3147
* Cache wrapper for {@link RemoteTermServiceValidation#invokeIsCodeSystemSupported(String)}.
3248
* HAPI's ValidationSupportChain probes {@code isCodeSystemSupported} per system per traversal,
@@ -54,7 +70,7 @@ public boolean cachedIsValueSetSupported(RemoteTermServiceValidation delegate, S
5470
*/
5571
@Cacheable(
5672
value = "lookupCodeCache",
57-
key = "T(java.util.Objects).hash(#code, #system, #displayLanguage, #propertyNames)",
73+
key = "#root.target.validateCodeCacheKey(#code, #system, #displayLanguage, #propertyNames)",
5874
unless = "#result == null"
5975
)
6076
public IValidationSupport.LookupCodeResult cachedLookupCode(
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
package com.lantanagroup.link.validation.providers;
2+
3+
import ca.uhn.fhir.context.support.IValidationSupport;
4+
import com.github.benmanes.caffeine.cache.Caffeine;
5+
import org.junit.jupiter.api.Test;
6+
import org.springframework.cache.Cache;
7+
import org.springframework.cache.CacheManager;
8+
import org.springframework.cache.annotation.EnableCaching;
9+
import org.springframework.cache.caffeine.CaffeineCacheManager;
10+
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
11+
import org.springframework.context.annotation.Bean;
12+
import org.springframework.context.annotation.Configuration;
13+
import org.springframework.data.redis.cache.RedisCacheConfiguration;
14+
import org.springframework.data.redis.cache.RedisCacheManager;
15+
import org.springframework.data.redis.cache.RedisCacheWriter;
16+
17+
import java.time.Duration;
18+
import java.util.Base64;
19+
import java.util.HashMap;
20+
import java.util.Map;
21+
import java.util.Objects;
22+
import java.util.Set;
23+
import java.util.concurrent.atomic.AtomicReference;
24+
25+
import static org.junit.jupiter.api.Assertions.assertEquals;
26+
import static org.junit.jupiter.api.Assertions.assertNotEquals;
27+
import static org.junit.jupiter.api.Assertions.assertNotNull;
28+
import static org.junit.jupiter.api.Assertions.assertNull;
29+
import static org.junit.jupiter.api.Assertions.assertSame;
30+
import static org.mockito.ArgumentMatchers.any;
31+
import static org.mockito.ArgumentMatchers.anyString;
32+
import static org.mockito.ArgumentMatchers.isNull;
33+
import static org.mockito.Mockito.doAnswer;
34+
import static org.mockito.Mockito.mock;
35+
import static org.mockito.Mockito.times;
36+
import static org.mockito.Mockito.verify;
37+
import static org.mockito.Mockito.when;
38+
39+
class ValidationCacheServiceCachingTest {
40+
private static final String CODE_SYSTEM = "http://example.org/system";
41+
private static final String DISPLAY = "Example display";
42+
private static final String VALUE_SET_URL = "http://example.org/ValueSet/example";
43+
44+
@Test
45+
void validateCodeCacheKey_keepsObjectsHashCollisionSeparateInBothCacheBackends() {
46+
ValidationCacheService cacheService = new ValidationCacheService();
47+
String aaKey = cacheService.validateCodeCacheKey(CODE_SYSTEM, "Aa", DISPLAY, VALUE_SET_URL);
48+
String bbKey = cacheService.validateCodeCacheKey(CODE_SYSTEM, "BB", DISPLAY, VALUE_SET_URL);
49+
50+
assertEquals(
51+
Objects.hash(CODE_SYSTEM, "Aa", DISPLAY, VALUE_SET_URL),
52+
Objects.hash(CODE_SYSTEM, "BB", DISPLAY, VALUE_SET_URL),
53+
"fixed regression inputs must collide under the former Objects.hash cache key");
54+
assertNotEquals(aaKey, bbKey);
55+
56+
CaffeineCacheManager caffeineCacheManager = new CaffeineCacheManager("validateCodeCache");
57+
caffeineCacheManager.setCaffeine(Caffeine.newBuilder());
58+
assertStoresSeparateEntries(caffeineCacheManager.getCache("validateCodeCache"), aaKey, bbKey);
59+
60+
Map<String, byte[]> redisEntries = new HashMap<>();
61+
RedisCacheWriter redisCacheWriter = mock(RedisCacheWriter.class);
62+
when(redisCacheWriter.get(anyString(), any(byte[].class))).thenAnswer(invocation ->
63+
redisEntries.get(serializeKey(invocation.getArgument(1))));
64+
doAnswer(invocation -> {
65+
redisEntries.put(serializeKey(invocation.getArgument(1)), invocation.getArgument(2));
66+
return null;
67+
}).when(redisCacheWriter).put(anyString(), any(byte[].class), any(byte[].class), any(Duration.class));
68+
69+
CacheManager redisCacheManager = RedisCacheManager.builder(redisCacheWriter)
70+
.cacheDefaults(RedisCacheConfiguration.defaultCacheConfig())
71+
.build();
72+
assertStoresSeparateEntries(redisCacheManager.getCache("validateCodeCache"), aaKey, bbKey);
73+
assertEquals(2, redisEntries.size(), "Redis must receive distinct serialized keys");
74+
}
75+
76+
@Test
77+
void validateCodeCacheKey_distinguishesNullEmptyAndLiteralNullForNullableInputs() {
78+
ValidationCacheService cacheService = new ValidationCacheService();
79+
assertDistinct(
80+
cacheService.validateCodeCacheKey(null, "code", DISPLAY, VALUE_SET_URL),
81+
cacheService.validateCodeCacheKey("", "code", DISPLAY, VALUE_SET_URL),
82+
cacheService.validateCodeCacheKey("null", "code", DISPLAY, VALUE_SET_URL));
83+
assertDistinct(
84+
cacheService.validateCodeCacheKey(CODE_SYSTEM, "code", null, VALUE_SET_URL),
85+
cacheService.validateCodeCacheKey(CODE_SYSTEM, "code", "", VALUE_SET_URL),
86+
cacheService.validateCodeCacheKey(CODE_SYSTEM, "code", "null", VALUE_SET_URL));
87+
assertDistinct(
88+
cacheService.validateCodeCacheKey(CODE_SYSTEM, "code", DISPLAY, null),
89+
cacheService.validateCodeCacheKey(CODE_SYSTEM, "code", DISPLAY, ""),
90+
cacheService.validateCodeCacheKey(CODE_SYSTEM, "code", DISPLAY, "null"));
91+
}
92+
93+
@Test
94+
void cachedValidateCode_cachesNonNullNegativeResultsAndHitsForRepeatedTuples() {
95+
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(CaffeineCacheTestConfiguration.class)) {
96+
ValidationCacheService cacheService = context.getBean(ValidationCacheService.class);
97+
RemoteTermServiceValidation delegate = mock(RemoteTermServiceValidation.class);
98+
when(delegate.invokeRemoteValidateCode(anyString(), anyString(), anyString(), anyString(), isNull()))
99+
.thenAnswer(invocation -> negativeResult(invocation.getArgument(1)));
100+
101+
IValidationSupport.CodeValidationResult aaResult =
102+
cacheService.cachedValidateCode(delegate, CODE_SYSTEM, "Aa", DISPLAY, VALUE_SET_URL);
103+
IValidationSupport.CodeValidationResult bbResult =
104+
cacheService.cachedValidateCode(delegate, CODE_SYSTEM, "BB", DISPLAY, VALUE_SET_URL);
105+
IValidationSupport.CodeValidationResult repeatedAaResult =
106+
cacheService.cachedValidateCode(delegate, CODE_SYSTEM, "Aa", DISPLAY, VALUE_SET_URL);
107+
108+
assertEquals(IValidationSupport.IssueSeverity.ERROR, aaResult.getSeverity());
109+
assertEquals(IValidationSupport.IssueSeverity.ERROR, bbResult.getSeverity());
110+
assertNotEquals(aaResult.getCode(), bbResult.getCode());
111+
assertSame(aaResult, repeatedAaResult, "the same tuple must be returned from the cache");
112+
verify(delegate, times(2)).invokeRemoteValidateCode(
113+
anyString(), anyString(), anyString(), anyString(), isNull());
114+
}
115+
}
116+
117+
@Test
118+
void cachedValidateCode_doesNotCacheNullResults() {
119+
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(CaffeineCacheTestConfiguration.class)) {
120+
ValidationCacheService cacheService = context.getBean(ValidationCacheService.class);
121+
RemoteTermServiceValidation delegate = mock(RemoteTermServiceValidation.class);
122+
when(delegate.invokeRemoteValidateCode(anyString(), anyString(), anyString(), anyString(), isNull()))
123+
.thenReturn(null);
124+
125+
assertNull(cacheService.cachedValidateCode(delegate, CODE_SYSTEM, "code", DISPLAY, VALUE_SET_URL));
126+
assertNull(cacheService.cachedValidateCode(delegate, CODE_SYSTEM, "code", DISPLAY, VALUE_SET_URL));
127+
verify(delegate, times(2)).invokeRemoteValidateCode(
128+
anyString(), anyString(), anyString(), anyString(), isNull());
129+
}
130+
}
131+
132+
@Test
133+
void cachedValidateCode_resolvesItsKeyWithoutApplicationClassInThreadContext() throws InterruptedException {
134+
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(CaffeineCacheTestConfiguration.class)) {
135+
ValidationCacheService cacheService = context.getBean(ValidationCacheService.class);
136+
RemoteTermServiceValidation delegate = mock(RemoteTermServiceValidation.class);
137+
when(delegate.invokeRemoteValidateCode(anyString(), anyString(), anyString(), anyString(), isNull()))
138+
.thenReturn(negativeResult("code"));
139+
140+
AtomicReference<Throwable> failure = new AtomicReference<>();
141+
Thread worker = new Thread(() -> {
142+
try {
143+
cacheService.cachedValidateCode(delegate, CODE_SYSTEM, "code", DISPLAY, VALUE_SET_URL);
144+
} catch (Throwable exception) {
145+
failure.set(exception);
146+
}
147+
}, "validation-cache-worker");
148+
worker.setContextClassLoader(ClassLoader.getPlatformClassLoader());
149+
worker.start();
150+
worker.join();
151+
152+
assertNull(failure.get());
153+
}
154+
}
155+
156+
private static void assertStoresSeparateEntries(Cache cache, String aaKey, String bbKey) {
157+
assertNotNull(cache);
158+
cache.put(aaKey, "Aa result");
159+
cache.put(bbKey, "BB result");
160+
assertEquals("Aa result", cache.get(aaKey, String.class));
161+
assertEquals("BB result", cache.get(bbKey, String.class));
162+
}
163+
164+
private static void assertDistinct(String... keys) {
165+
assertEquals(keys.length, Set.of(keys).size());
166+
}
167+
168+
private static IValidationSupport.CodeValidationResult negativeResult(String code) {
169+
IValidationSupport.CodeValidationResult result = new IValidationSupport.CodeValidationResult();
170+
result.setCode(code);
171+
result.setSeverity(IValidationSupport.IssueSeverity.ERROR);
172+
return result;
173+
}
174+
175+
private static String serializeKey(byte[] key) {
176+
return Base64.getEncoder().encodeToString(key);
177+
}
178+
179+
@Configuration(proxyBeanMethods = false)
180+
@EnableCaching
181+
static class CaffeineCacheTestConfiguration {
182+
@Bean
183+
CacheManager cacheManager() {
184+
CaffeineCacheManager cacheManager = new CaffeineCacheManager("validateCodeCache");
185+
cacheManager.setCaffeine(Caffeine.newBuilder());
186+
return cacheManager;
187+
}
188+
189+
@Bean
190+
ValidationCacheService validationCacheService() {
191+
return new ValidationCacheService();
192+
}
193+
}
194+
}

0 commit comments

Comments
 (0)