diff --git a/dspace-api/pom.xml b/dspace-api/pom.xml index 872f3f8540..4ab34b93a7 100644 --- a/dspace-api/pom.xml +++ b/dspace-api/pom.xml @@ -903,14 +903,12 @@ org.apache.velocity velocity-engine-core - 2.0 jar org.xmlunit xmlunit-core - 2.6.3 test @@ -934,6 +932,81 @@ test + + org.mock-server + mockserver-junit-rule + 5.11.2 + test + + + + + + + org.apache.commons + commons-text + 1.9 + + + io.netty + netty-buffer + 4.1.53.Final + + + io.netty + netty-transport + 4.1.53.Final + + + io.netty + netty-common + 4.1.53.Final + + + io.netty + netty-handler + 4.1.53.Final + + + io.netty + netty-codec + 4.1.53.Final + + + org.apache.velocity + velocity-engine-core + 2.2 + + + org.xmlunit + xmlunit-core + 2.8.0 + test + + + com.github.java-json-tools + json-schema-validator + 2.2.14 + + + jakarta.xml.bind + jakarta.xml.bind-api + 2.3.3 + + + javax.validation + validation-api + 2.0.1.Final + + + io.swagger + swagger-core + 1.6.2 + + + + diff --git a/dspace-api/src/main/java/org/dspace/authority/AuthoritySolrServiceImpl.java b/dspace-api/src/main/java/org/dspace/authority/AuthoritySolrServiceImpl.java index 8d9ab0c5fc..dab8cd5b2e 100644 --- a/dspace-api/src/main/java/org/dspace/authority/AuthoritySolrServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/authority/AuthoritySolrServiceImpl.java @@ -11,6 +11,8 @@ import java.io.IOException; import java.net.MalformedURLException; import java.util.ArrayList; import java.util.List; +import javax.inject.Inject; +import javax.inject.Named; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -22,6 +24,7 @@ import org.apache.solr.client.solrj.response.FacetField; import org.apache.solr.client.solrj.response.QueryResponse; import org.apache.solr.common.SolrInputDocument; import org.dspace.authority.indexer.AuthorityIndexingService; +import org.dspace.service.impl.HttpConnectionPoolService; import org.dspace.services.ConfigurationService; import org.dspace.services.factory.DSpaceServicesFactory; @@ -35,6 +38,9 @@ public class AuthoritySolrServiceImpl implements AuthorityIndexingService, Autho private static final Logger log = LogManager.getLogger(AuthoritySolrServiceImpl.class); + @Inject @Named("solrHttpConnectionPoolService") + private HttpConnectionPoolService httpConnectionPoolService; + protected AuthoritySolrServiceImpl() { } @@ -54,7 +60,9 @@ public class AuthoritySolrServiceImpl implements AuthorityIndexingService, Autho log.debug("Solr authority URL: " + solrService); - HttpSolrClient solrServer = new HttpSolrClient.Builder(solrService).build(); + HttpSolrClient solrServer = new HttpSolrClient.Builder(solrService) + .withHttpClient(httpConnectionPoolService.getClient()) + .build(); solrServer.setBaseURL(solrService); SolrQuery solrQuery = new SolrQuery().setQuery("*:*"); diff --git a/dspace-api/src/main/java/org/dspace/discovery/SolrSearchCore.java b/dspace-api/src/main/java/org/dspace/discovery/SolrSearchCore.java index 47288ece34..b430a0c973 100644 --- a/dspace-api/src/main/java/org/dspace/discovery/SolrSearchCore.java +++ b/dspace-api/src/main/java/org/dspace/discovery/SolrSearchCore.java @@ -8,6 +8,7 @@ package org.dspace.discovery; import java.io.IOException; +import javax.inject.Named; import org.apache.commons.validator.routines.UrlValidator; import org.apache.logging.log4j.LogManager; @@ -18,13 +19,14 @@ import org.apache.solr.client.solrj.SolrRequest; import org.apache.solr.client.solrj.SolrServerException; import org.apache.solr.client.solrj.impl.HttpSolrClient; import org.dspace.discovery.indexobject.IndexableItem; +import org.dspace.service.impl.HttpConnectionPoolService; import org.dspace.services.ConfigurationService; import org.dspace.services.factory.DSpaceServicesFactory; import org.dspace.storage.rdbms.DatabaseUtils; import org.springframework.beans.factory.annotation.Autowired; /** - * Bean containing the SolrClient for the search core + * Bean containing the SolrClient for the search core. * @author Kevin Van de Velde (kevin at atmire dot com) */ public class SolrSearchCore { @@ -34,6 +36,8 @@ public class SolrSearchCore { protected IndexingService indexingService; @Autowired protected ConfigurationService configurationService; + @Autowired @Named("solrHttpConnectionPoolService") + protected HttpConnectionPoolService httpConnectionPoolService; /** * SolrServer for processing indexing events. @@ -79,7 +83,9 @@ public class SolrSearchCore { .getBooleanProperty("discovery.solr.url.validation.enabled", true)) { try { log.debug("Solr URL: " + solrService); - HttpSolrClient solrServer = new HttpSolrClient.Builder(solrService).build(); + HttpSolrClient solrServer = new HttpSolrClient.Builder(solrService) + .withHttpClient(httpConnectionPoolService.getClient()) + .build(); solrServer.setBaseURL(solrService); solrServer.setUseMultiPartPost(true); diff --git a/dspace-api/src/main/java/org/dspace/service/impl/HttpConnectionPoolService.java b/dspace-api/src/main/java/org/dspace/service/impl/HttpConnectionPoolService.java new file mode 100644 index 0000000000..c5f7c46b58 --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/service/impl/HttpConnectionPoolService.java @@ -0,0 +1,196 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.service.impl; + +import java.util.concurrent.TimeUnit; +import javax.annotation.PostConstruct; +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +import org.apache.http.HeaderElement; +import org.apache.http.HeaderElementIterator; +import org.apache.http.HttpResponse; +import org.apache.http.conn.ConnectionKeepAliveStrategy; +import org.apache.http.conn.HttpClientConnectionManager; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; +import org.apache.http.message.BasicHeaderElementIterator; +import org.apache.http.protocol.HTTP; +import org.apache.http.protocol.HttpContext; +import org.dspace.services.ConfigurationService; + +/** + * Factory for HTTP clients sharing a pool of connections. + * + *

You may create multiple pools. Each is identified by a configuration + * "prefix" (passed to the constructor) which is used to create names of + * properties which will configure the pool. The properties are: + * + *

+ *
PREFIX.client.keepAlive
+ *
Default keep-alive time for open connections, in milliseconds
+ *
PREFIX.client.maxTotalConnections
+ *
maximum open connections
+ *
PREFIX.client.maxPerRoute
+ *
maximum open connections per service instance
+ *
PREFIX.client.timeToLive
+ *
maximum lifetime of a pooled connection, in seconds
+ *
+ * + * @author Mark H. Wood + */ +@Named +@Singleton +public class HttpConnectionPoolService { + @Inject + ConfigurationService configurationService; + + /** Configuration properties will begin with this string. */ + private final String configPrefix; + + /** Maximum number of concurrent pooled connections. */ + private static final int DEFAULT_MAX_TOTAL_CONNECTIONS = 20; + + /** Maximum number of concurrent pooled connections per route. */ + private static final int DEFAULT_MAX_PER_ROUTE = 15; + + /** Keep connections open at least this long, if the response did not + * specify: milliseconds + */ + private static final int DEFAULT_KEEPALIVE = 5 * 1000; + + /** Pooled connection maximum lifetime: seconds */ + private static final int DEFAULT_TTL = 10 * 60; + + /** Clean up stale connections this often: milliseconds */ + private static final int CHECK_INTERVAL = 1000; + + /** Connection idle if unused for this long: seconds */ + private static final int IDLE_INTERVAL = 30; + + private PoolingHttpClientConnectionManager connManager; + + private final ConnectionKeepAliveStrategy keepAliveStrategy + = new KeepAliveStrategy(); + + /** + * Construct a pool for a given set of configuration properties. + * + * @param configPrefix Configuration property names will begin with this. + */ + public HttpConnectionPoolService(String configPrefix) { + this.configPrefix = configPrefix; + } + + @PostConstruct + protected void init() { + connManager = new PoolingHttpClientConnectionManager( + configurationService.getIntProperty(configPrefix + ".client.timeToLive", DEFAULT_TTL), + TimeUnit.SECONDS); + + connManager.setMaxTotal(configurationService.getIntProperty( + configPrefix + ".client.maxTotalConnections", DEFAULT_MAX_TOTAL_CONNECTIONS)); + connManager.setDefaultMaxPerRoute( + configurationService.getIntProperty(configPrefix + ".client.maxPerRoute", + DEFAULT_MAX_PER_ROUTE)); + + Thread connectionMonitor = new IdleConnectionMonitorThread(connManager); + connectionMonitor.setDaemon(true); + connectionMonitor.start(); + } + + /** + * Create an HTTP client which uses a pooled connection. + * + * @return the client. + */ + public CloseableHttpClient getClient() { + CloseableHttpClient httpClient = HttpClientBuilder.create() + .setKeepAliveStrategy(keepAliveStrategy) + .setConnectionManager(connManager) + .build(); + return httpClient; + } + + /** + * A connection keep-alive strategy that obeys the Keep-Alive header and + * applies a default if none is given. + * + * Swiped from https://www.baeldung.com/httpclient-connection-management + */ + public class KeepAliveStrategy + implements ConnectionKeepAliveStrategy { + @Override + public long getKeepAliveDuration(HttpResponse response, + HttpContext context) { + HeaderElementIterator it = new BasicHeaderElementIterator( + response.headerIterator(HTTP.CONN_KEEP_ALIVE)); + while (it.hasNext()) { + HeaderElement he = it.nextElement(); + String name = he.getName(); + String value = he.getValue(); + if (value != null && "timeout".equalsIgnoreCase(name)) { + return Long.parseLong(value) * 1000; + } + } + + // If server did not request keep-alive, use configured value. + return configurationService.getIntProperty(configPrefix + ".client.keepAlive", + DEFAULT_KEEPALIVE); + } + } + + /** + * Clean up stale connections. + * + * Swiped from https://www.baeldung.com/httpclient-connection-management + */ + public class IdleConnectionMonitorThread + extends Thread { + private final HttpClientConnectionManager connMgr; + private volatile boolean shutdown; + + /** + * Constructor. + * + * @param connMgr the manager to be monitored. + */ + public IdleConnectionMonitorThread( + PoolingHttpClientConnectionManager connMgr) { + super(); + this.connMgr = connMgr; + } + + @Override + public void run() { + try { + while (!shutdown) { + synchronized (this) { + wait(CHECK_INTERVAL); + connMgr.closeExpiredConnections(); + connMgr.closeIdleConnections(IDLE_INTERVAL, TimeUnit.SECONDS); + } + } + } catch (InterruptedException ex) { + shutdown(); + } + } + + /** + * Cause a controlled exit from the thread. + */ + public void shutdown() { + shutdown = true; + synchronized (this) { + notifyAll(); + } + } + } +} diff --git a/dspace-api/src/main/java/org/dspace/statistics/SolrStatisticsCore.java b/dspace-api/src/main/java/org/dspace/statistics/SolrStatisticsCore.java index 345084ef6b..9ad72cbf31 100644 --- a/dspace-api/src/main/java/org/dspace/statistics/SolrStatisticsCore.java +++ b/dspace-api/src/main/java/org/dspace/statistics/SolrStatisticsCore.java @@ -9,9 +9,12 @@ package org.dspace.statistics; import static org.apache.logging.log4j.LogManager.getLogger; +import javax.inject.Named; + import org.apache.logging.log4j.Logger; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.impl.HttpSolrClient; +import org.dspace.service.impl.HttpConnectionPoolService; import org.dspace.services.ConfigurationService; import org.springframework.beans.factory.annotation.Autowired; @@ -20,13 +23,16 @@ import org.springframework.beans.factory.annotation.Autowired; */ public class SolrStatisticsCore { - private static Logger log = getLogger(SolrStatisticsCore.class); + private static final Logger log = getLogger(); protected SolrClient solr = null; @Autowired private ConfigurationService configurationService; + @Autowired @Named("solrHttpConnectionPoolService") + private HttpConnectionPoolService httpConnectionPoolService; + /** * Returns the {@link SolrClient} for the Statistics core. * Initializes it if needed. @@ -50,7 +56,9 @@ public class SolrStatisticsCore { log.info("usage-statistics.dbfile: {}", configurationService.getProperty("usage-statistics.dbfile")); try { - solr = new HttpSolrClient.Builder(solrService).build(); + solr = new HttpSolrClient.Builder(solrService) + .withHttpClient(httpConnectionPoolService.getClient()) + .build(); } catch (Exception e) { log.error("Error accessing Solr server configured in 'solr-statistics.server'", e); } diff --git a/dspace-api/src/test/java/org/dspace/service/impl/HttpConnectionPoolServiceTest.java b/dspace-api/src/test/java/org/dspace/service/impl/HttpConnectionPoolServiceTest.java new file mode 100644 index 0000000000..60964cd004 --- /dev/null +++ b/dspace-api/src/test/java/org/dspace/service/impl/HttpConnectionPoolServiceTest.java @@ -0,0 +1,96 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.service.impl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; + +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.client.methods.RequestBuilder; +import org.apache.http.client.utils.URIBuilder; +import org.apache.http.impl.client.CloseableHttpClient; +import org.dspace.AbstractDSpaceTest; +import org.dspace.services.ConfigurationService; +import org.dspace.services.factory.DSpaceServicesFactory; +import org.eclipse.jetty.http.HttpStatus; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.mockserver.client.MockServerClient; +import org.mockserver.junit.MockServerRule; + +/** + * + * @author Mark H. Wood + */ +public class HttpConnectionPoolServiceTest + extends AbstractDSpaceTest { + private static ConfigurationService configurationService; + + @Rule + public MockServerRule mockServerRule = new MockServerRule(this); + + private MockServerClient mockServerClient; + + @BeforeClass + public static void initClass() { + configurationService = DSpaceServicesFactory.getInstance() + .getConfigurationService(); + } + + /** + * Test of getClient method, of class HttpConnectionPoolService. + * @throws java.io.IOException if a connection cannot be closed. + * @throws java.net.URISyntaxException when an invalid URI is constructed. + */ + @Test + public void testGetClient() + throws IOException, URISyntaxException { + System.out.println("getClient"); + + configurationService.setProperty("solr.client.maxTotalConnections", 2); + configurationService.setProperty("solr.client.maxPerRoute", 2); + HttpConnectionPoolService instance = new HttpConnectionPoolService("solr"); + instance.configurationService = configurationService; + instance.init(); + + final String testPath = "/test"; + mockServerClient.when( + request() + .withPath(testPath) + ).respond( + response() + .withStatusCode(HttpStatus.OK_200) + ); + + try (CloseableHttpClient httpClient = instance.getClient()) { + assertNotNull("getClient should always return a client", httpClient); + + URI uri = new URIBuilder() + .setScheme("http") + .setHost("localhost") + .setPort(mockServerClient.getPort()) + .setPath(testPath) + .build(); + System.out.println(uri.toString()); + HttpUriRequest request = RequestBuilder.get(uri) + .build(); + try (CloseableHttpResponse response = httpClient.execute(request)) { + assertEquals("Response status should be OK", HttpStatus.OK_200, + response.getStatusLine().getStatusCode()); + } + } + } +} diff --git a/dspace-oai/src/main/java/org/dspace/xoai/services/impl/solr/DSpaceSolrServerResolver.java b/dspace-oai/src/main/java/org/dspace/xoai/services/impl/solr/DSpaceSolrServerResolver.java index c544ec1659..6637d7f26f 100644 --- a/dspace-oai/src/main/java/org/dspace/xoai/services/impl/solr/DSpaceSolrServerResolver.java +++ b/dspace-oai/src/main/java/org/dspace/xoai/services/impl/solr/DSpaceSolrServerResolver.java @@ -7,11 +7,14 @@ */ package org.dspace.xoai.services.impl.solr; +import javax.inject.Named; + import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.SolrServerException; import org.apache.solr.client.solrj.impl.HttpSolrClient; +import org.dspace.service.impl.HttpConnectionPoolService; import org.dspace.xoai.services.api.config.ConfigurationService; import org.dspace.xoai.services.api.solr.SolrServerResolver; import org.springframework.beans.factory.annotation.Autowired; @@ -23,12 +26,17 @@ public class DSpaceSolrServerResolver implements SolrServerResolver { @Autowired private ConfigurationService configurationService; + @Autowired @Named("solrHttpConnectionPoolService") + private HttpConnectionPoolService httpConnectionPoolService; + @Override public SolrClient getServer() throws SolrServerException { if (server == null) { String serverUrl = configurationService.getProperty("oai.solr.url"); try { - server = new HttpSolrClient.Builder(serverUrl).build(); + server = new HttpSolrClient.Builder(serverUrl) + .withHttpClient(httpConnectionPoolService.getClient()) + .build(); log.debug("OAI Solr Server Initialized"); } catch (Exception e) { log.error("Could not initialize OAI Solr Server at " + serverUrl , e); diff --git a/dspace-oai/src/main/java/org/dspace/xoai/solr/DSpaceSolrServer.java b/dspace-oai/src/main/java/org/dspace/xoai/solr/DSpaceSolrServer.java index 158f73be1d..bf6b46807b 100644 --- a/dspace-oai/src/main/java/org/dspace/xoai/solr/DSpaceSolrServer.java +++ b/dspace-oai/src/main/java/org/dspace/xoai/solr/DSpaceSolrServer.java @@ -13,6 +13,7 @@ import org.apache.logging.log4j.Logger; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.SolrServerException; import org.apache.solr.client.solrj.impl.HttpSolrClient; +import org.dspace.service.impl.HttpConnectionPoolService; import org.dspace.services.ConfigurationService; import org.dspace.services.factory.DSpaceServicesFactory; @@ -33,9 +34,16 @@ public class DSpaceSolrServer { if (_server == null) { ConfigurationService configurationService = DSpaceServicesFactory.getInstance().getConfigurationService(); + HttpConnectionPoolService httpConnectionPoolService + = DSpaceServicesFactory.getInstance() + .getServiceManager() + .getServiceByName("solrHttpConnectionPoolService", + HttpConnectionPoolService.class); String serverUrl = configurationService.getProperty("oai.solr.url"); try { - _server = new HttpSolrClient.Builder(serverUrl).build(); + _server = new HttpSolrClient.Builder(serverUrl) + .withHttpClient(httpConnectionPoolService.getClient()) + .build(); log.debug("OAI Solr Server Initialized"); } catch (Exception e) { log.error("Could not initialize OAI Solr Server at " + serverUrl , e); diff --git a/dspace/config/dspace.cfg b/dspace/config/dspace.cfg index 1979f3d3f6..c261f0dd0b 100644 --- a/dspace/config/dspace.cfg +++ b/dspace/config/dspace.cfg @@ -45,6 +45,22 @@ default.language = en_US # Since DSpace 7, SOLR must be installed as a stand-alone service solr.server = http://localhost:8983/solr +# Solr connection pool. +# If you change these values, the changes are not effective until DSpace is +# restarted. +# +# Maximum open connections to Solr: +# solr.client.maxTotalConnections = 20 +# +# Maximum open connections per Solr instance: +# solr.client.maxPerRoute = 15 +# +# Default keep-alive time for open Solr connections, in milliseconds: +# solr.client.keepAlive = 5000 +# +# Maximum lifetime of a pooled connection, in seconds: +# solr.client.timeToLive = 600 + ##### Database settings ##### # DSpace only supports two database types: PostgreSQL or Oracle diff --git a/dspace/config/spring/api/core-services.xml b/dspace/config/spring/api/core-services.xml index 413d0b814a..526c448b46 100644 --- a/dspace/config/spring/api/core-services.xml +++ b/dspace/config/spring/api/core-services.xml @@ -63,6 +63,13 @@ + + + +