https://github.com/spring-projects/spring-framework/blob/main/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/lookup/AbstractRoutingDataSource.java
Problem:
I am using a Lazy Connection approach where, if the value in the ThreadLocal does not exist as a key in targetDataSources, a DataSource is dynamically created.
// My code Example
String key = ThreadLocal.get();
if (!this.targetDataSources.containsKey(key)) {
this.targetDataSources.computeIfAbsent(key, (k) -> { ... });
this.afterPropertiesSet();
}
However, when looking at the afterPropertiesSet()
method, the this.resolvedDataSources
map is initialized and then populated sequentially with this.targetDataSources
. This results in concurrency issues when this.afterPropertiesSet()
is called, causing the AbstractRoutingDatasource.determineTargetDataSource()
method to throw a new IllegalStateException("Cannot determine target DataSource for lookup key [" + lookupKey + "]")
error.
To test this, I added a 1-second sleep in the thread and observed that when an additional thread was running, the expected error occurred.
// AbstractRoutingDatasource.class
public void initialize() {
if (this.targetDataSources == null) {
throw new IllegalArgumentException("Property 'targetDataSources' is required");
}
this.resolvedDataSources = CollectionUtils.newHashMap(this.targetDataSources.size());
try { // Add 1 second Time sleep.
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
this.targetDataSources.forEach((key, value) -> {
Object lookupKey = resolveSpecifiedLookupKey(key);
DataSource dataSource = resolveSpecifiedDataSource(value);
this.resolvedDataSources.put(lookupKey, dataSource);
});
if (this.defaultTargetDataSource != null) {
this.resolvedDefaultDataSource = resolveSpecifiedDataSource(this.defaultTargetDataSource);
}
}
@Test
void setTargetDataSources() {
ThreadLocal<String> lookupKey = new ThreadLocal<>();
AbstractRoutingDataSource routingDataSource = new AbstractRoutingDataSource() {
@Override
protected Object determineCurrentLookupKey() {
return lookupKey.get();
}
};
DataSource ds1 = new StubDataSource();
DataSource ds2 = new StubDataSource();
MapDataSourceLookup dataSourceLookup = new MapDataSourceLookup();
dataSourceLookup.addDataSource("dataSource2", ds2);
routingDataSource.setDataSourceLookup(dataSourceLookup);
routingDataSource.setTargetDataSources(Map.of("ds1", ds1, "ds2", "dataSource2"));
routingDataSource.afterPropertiesSet();
new Thread(() -> routingDataSource.afterPropertiesSet()).start(); // Another Thread call afterPropertiesSet() Method
lookupKey.set("ds1");
assertThat(routingDataSource.determineTargetDataSource()).isSameAs(ds1);
lookupKey.set("ds2");
assertThat(routingDataSource.determineTargetDataSource()).isSameAs(ds2);
}
java.lang.IllegalStateException: Cannot determine target DataSource for lookup key [ds2]
at org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource.determineTargetDataSource(AbstractRoutingDataSource.java:267)
at org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSourceTests.setTargetDataSources(AbstractRoutingDataSourceTests.java:60)
at java.base/java.lang.reflect.Method.invoke(Method.java:569)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
Propose:
Therefore, instead of initializing and inserting sequentially, how about changing the code to first create a temporary map and then replace the existing map with the new one?
public void initialize() {
if (this.targetDataSources == null) {
throw new IllegalArgumentException("Property 'targetDataSources' is required");
}
Map<Object, DataSource> newDataSourcesMap = CollectionUtils.newHashMap(this.targetDataSources.size()); // Change Code
this.targetDataSources.forEach((key, value) -> {
Object lookupKey = resolveSpecifiedLookupKey(key);
DataSource dataSource = resolveSpecifiedDataSource(value);
newDataSourcesMap.put(lookupKey, dataSource); // Change Code
});
this.resolvedDataSources = newDataSourcesMap; // Change Code
if (this.defaultTargetDataSource != null) {
this.resolvedDefaultDataSource = resolveSpecifiedDataSource(this.defaultTargetDataSource);
}
}
TEST:
public void initialize() {
if (this.targetDataSources == null) {
throw new IllegalArgumentException("Property 'targetDataSources' is required");
}
Map<Object, DataSource> newDataSourcesMap = CollectionUtils.newHashMap(this.targetDataSources.size());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
this.targetDataSources.forEach((key, value) -> {
Object lookupKey = resolveSpecifiedLookupKey(key);
DataSource dataSource = resolveSpecifiedDataSource(value);
newDataSourcesMap.put(lookupKey, dataSource);
});
this.resolvedDataSources = newDataSourcesMap;
if (this.defaultTargetDataSource != null) {
this.resolvedDefaultDataSource = resolveSpecifiedDataSource(this.defaultTargetDataSource);
}
}
// Test
@Test
void setTargetDataSources() {
ThreadLocal<String> lookupKey = new ThreadLocal<>();
AbstractRoutingDataSource routingDataSource = new AbstractRoutingDataSource() {
@Override
protected Object determineCurrentLookupKey() {
return lookupKey.get();
}
};
DataSource ds1 = new StubDataSource();
DataSource ds2 = new StubDataSource();
MapDataSourceLookup dataSourceLookup = new MapDataSourceLookup();
dataSourceLookup.addDataSource("dataSource2", ds2);
routingDataSource.setDataSourceLookup(dataSourceLookup);
routingDataSource.setTargetDataSources(Map.of("ds1", ds1, "ds2", "dataSource2"));
routingDataSource.afterPropertiesSet();
new Thread(() -> routingDataSource.afterPropertiesSet()).start();
lookupKey.set("ds1");
assertThat(routingDataSource.determineTargetDataSource()).isSameAs(ds1);
lookupKey.set("ds2");
assertThat(routingDataSource.determineTargetDataSource()).isSameAs(ds2);
}
Comment From: snicoll
Sorry, but AbstractRoutingDatasource
is not meant to deal with concurrent initialization as you've described. If you need such a complexity, you'll have to roll out your own version.
Comment From: ghdcksgml1
Sorry, but
AbstractRoutingDatasource
is not meant to deal with concurrent initialization as you've described. If you need such a complexity, you'll have to roll out your own version.
Why does resolvedDataSources need to be initialized and populated one by one with data from targetDataSources?
From my perspective, the approach I suggested—completing the HashMap first and then assigning it to resolvedDataSources—seems safer.