diff --git a/README.md b/README.md index 0e5f2da4..bf3b9975 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ SocketChannelProvider socketChannelProvider = new SingleSocketChannelProviderImp TarantoolClient client = new TarantoolClientImpl(socketChannelProvider, config); ``` -You could implement your own `SocketChannelProvider`. It should return +You could implement your own `SocketChannelProvider`. It should return a connected `SocketChannel`. Feel free to implement `get(int retryNumber, Throwable lastError)` using your appropriate strategy to obtain the channel. The strategy can take into account current attempt number (retryNumber) and the last transient error occurred on @@ -177,7 +177,7 @@ Supported options are follow: 13. `retryCount` is a hint and can be passed to the socket providers which implement `ConfigurableSocketChannelProvider` interface. This hint should be interpreter as a maximal number of attempts to connect to Tarantool instance. - Default value is `3`. + Default value is `3`. 14. `operationExpiryTimeMillis` is a default request timeout in ms. Default value is `1000` (1 second). @@ -283,6 +283,31 @@ order in which they were added to the batch" - The driver continues processing the remaining commands in a batch once execution of a command fails. +### Connection Fail-over + +To enable simple connection fail-over you can specify multiple nodes (host and port pairs) in the connection url. The +driver will try to once connect to each of them in order until the connection succeeds. If none succeed, a normal +connection exception is thrown. + +The syntax for the connection url is: + +jdbc:tarantool://[user-info@][nodes][?parameters] + +where +* `user-info` is an optional colon separated username and password like `admin:secret`; +* `nodes` is a set of comma separated pairs like `host1[:port1][,host2[:port2] ... ]`; +* `parameters` is a set of optional cluster parameters (in addition to other ones) such as + `clusterDiscoveryEntryFunction` and `clusterDiscoveryDelayMillis` (see [Cluster support](#cluster-support) for more + details). + +For instance, + +jdbc:postgresql://tnt-node-1:3301,tnt-node2,tnt-node-3:3302?clusterDiscoveryEntryFunction=fetchNodes + +will try to connect to the Tarantool servers using initial set of nodes in the order they were listed in the URL. Also, +there is `clusterDiscoveryEntryFunction` parameter specified to enable cluster nodes discovery that can refresh the list +of available nodes. + ## Cluster support To be more fault-tolerant the connector provides cluster extensions. In @@ -307,7 +332,7 @@ connection to _one instance_ before failing an attempt. The provider requires positive retry count to work properly. The socket timeout is used to limit an interval between connections attempts per instance. In other words, the provider follows a pattern _connection should succeed after N attempts with M interval between -them at max_. +them at max_. ### Basic cluster client usage @@ -326,7 +351,7 @@ an initial list of nodes: ```java String[] nodes = new String[] { "myHost1:3301", "myHost2:3302", "myHost3:3301" }; TarantoolClusterClient client = new TarantoolClusterClient(config, nodes); -``` +``` 3. Work with the client using same API as defined in `TarantoolClient`: @@ -336,10 +361,10 @@ client.syncOps().insert(23, Arrays.asList(1, 1)); ### Auto-discovery -Auto-discovery feature allows a cluster client to fetch addresses of +Auto-discovery feature allows a cluster client to fetch addresses of cluster nodes to reflect changes related to the cluster topology. To achieve -this you have to create a Lua function on the server side which returns -a single array result. Client periodically polls the server to obtain a +this you have to create a Lua function on the server side which returns +a single array result. Client periodically polls the server to obtain a fresh list and apply it if its content changes. 1. On the server side create a function which returns nodes: @@ -356,20 +381,20 @@ You need to pay attention to a function contract we are currently supporting: and an optional colon followed by digits of the port). Also, the port must be in a range between 1 and 65535 if one is presented. * A discovery function _may_ return multi-results but the client takes - into account only first of them (i.e. `return {'host:3301'}, discovery_delay`, where + into account only first of them (i.e. `return {'host:3301'}, discovery_delay`, where the second result is unused). Even more, any extra results __are reserved__ by the client in order to extend its contract with a backward compatibility. * A discovery function _should NOT_ return no results, empty result, wrong type result, and Lua errors. The client discards such kinds of results but it does not affect the discovery - process for next scheduled tasks. + process for next scheduled tasks. 2. On the client side configure discovery settings in `TarantoolClusterClientConfig`: ```java TarantoolClusterClientConfig config = new TarantoolClusterClientConfig(); // fill other settings -config.clusterDiscoveryEntryFunction = "get_cluster_nodes"; // discovery function used to fetch nodes -config.clusterDiscoveryDelayMillis = 60_000; // how often client polls the discovery server +config.clusterDiscoveryEntryFunction = "get_cluster_nodes"; // discovery function used to fetch nodes +config.clusterDiscoveryDelayMillis = 60_000; // how often client polls the discovery server ``` 3. Create a client using the config made above. @@ -383,21 +408,21 @@ client.syncOps().insert(45, Arrays.asList(1, 1)); * You need to set _not empty_ value to `clusterDiscoveryEntryFunction` to enable auto-discovery feature. * There are only two cases when a discovery task runs: just after initialization of the cluster - client and a periodical scheduler timeout defined in `TarantoolClusterClientConfig.clusterDiscoveryDelayMillis`. + client and a periodical scheduler timeout defined in `TarantoolClusterClientConfig.clusterDiscoveryDelayMillis`. * A discovery task always uses an active client connection to get the nodes list. It's in your responsibility to provide a function availability as well as a consistent nodes list on all instances you initially set or obtain from the task. * Every address which is unmatched with `host[:port]` pattern will be filtered out from the target addresses list. * If some error occurs while a discovery task is running then this task - will be aborted without any after-effects for next task executions. These cases, for instance, are - a wrong function result (see discovery function contract) or a broken connection. + will be aborted without any after-effects for next task executions. These cases, for instance, are + a wrong function result (see discovery function contract) or a broken connection. There is an exception if the client is closed then discovery process will stop permanently. * It's possible to obtain a list which does NOT contain the node we are currently - connected to. It leads the client to try to reconnect to another node from the + connected to. It leads the client to try to reconnect to another node from the new list. It may take some time to graceful disconnect from the current node. The client does its best to catch the moment when there are no pending responses - and perform a reconnection. + and perform a reconnection. ### Cluster client config options @@ -425,7 +450,7 @@ directly via SLF4J interface. The logging facade offers several ways in integrate its internal logging with foreign one in order: * Using system property `org.tarantool.logging.provider`. Supported values are *jdk* and *slf4j* - for the java util logging and SLF4J/Logback respectively. For instance, use + for the java util logging and SLF4J/Logback respectively. For instance, use `java -Dorg.tarantool.logging.provider=slf4j <...>`. * Using Java SPI mechanism. Implement your own provider org.tarantool.logging.LoggerProvider @@ -437,7 +462,7 @@ cat META-INF/services/org.tarantool.logging.LoggerProvider org.mydomain.MySimpleLoggerProvider ``` -* CLASSPATH exploring. Now, the connector will use SLF4J if Logback is also in use. +* CLASSPATH exploring. Now, the connector will use SLF4J if Logback is also in use. * If nothing is successful JUL will be used by default. @@ -452,10 +477,10 @@ org.mydomain.MySimpleLoggerProvider ## Building To run unit tests use: - + ```bash ./mvnw clean test -``` +``` To run integration tests use: diff --git a/src/main/java/org/tarantool/RoundRobinSocketProviderImpl.java b/src/main/java/org/tarantool/RoundRobinSocketProviderImpl.java index 5817665e..99bd5ef5 100644 --- a/src/main/java/org/tarantool/RoundRobinSocketProviderImpl.java +++ b/src/main/java/org/tarantool/RoundRobinSocketProviderImpl.java @@ -61,7 +61,18 @@ public class RoundRobinSocketProviderImpl extends BaseSocketChannelProvider impl * @throws IllegalArgumentException if addresses aren't provided */ public RoundRobinSocketProviderImpl(String... addresses) { - updateAddressList(Arrays.asList(addresses)); + this(Arrays.asList(addresses)); + } + + /** + * Constructs an instance. + * + * @param addresses optional list of addresses in a form of host[:port] + * + * @throws IllegalArgumentException if addresses aren't provided + */ + public RoundRobinSocketProviderImpl(List addresses) { + updateAddressList(addresses); setRetriesLimit(DEFAULT_RETRIES_PER_CONNECTION); } diff --git a/src/main/java/org/tarantool/TarantoolClusterClient.java b/src/main/java/org/tarantool/TarantoolClusterClient.java index e3697a91..1f0d29f5 100644 --- a/src/main/java/org/tarantool/TarantoolClusterClient.java +++ b/src/main/java/org/tarantool/TarantoolClusterClient.java @@ -10,7 +10,9 @@ import java.io.IOException; import java.net.SocketAddress; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; +import java.util.List; import java.util.Objects; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -55,6 +57,16 @@ public class TarantoolClusterClient extends TarantoolClientImpl { * @param addresses Array of addresses in the form of host[:port]. */ public TarantoolClusterClient(TarantoolClusterClientConfig config, String... addresses) { + this(config, makeClusterSocketProvider(Arrays.asList(addresses))); + } + + /** + * Constructs a new cluster client. + * + * @param config Configuration. + * @param addresses List of addresses in the form of host[:port]. + */ + public TarantoolClusterClient(TarantoolClusterClientConfig config, List addresses) { this(config, makeClusterSocketProvider(addresses)); } @@ -270,7 +282,7 @@ public void refreshInstances() { } } - private static RoundRobinSocketProviderImpl makeClusterSocketProvider(String[] addresses) { + private static RoundRobinSocketProviderImpl makeClusterSocketProvider(List addresses) { return new RoundRobinSocketProviderImpl(addresses); } diff --git a/src/main/java/org/tarantool/jdbc/SQLConnection.java b/src/main/java/org/tarantool/jdbc/SQLConnection.java index 15f3258a..b3305d4a 100644 --- a/src/main/java/org/tarantool/jdbc/SQLConnection.java +++ b/src/main/java/org/tarantool/jdbc/SQLConnection.java @@ -5,10 +5,11 @@ import org.tarantool.Key; import org.tarantool.SocketChannelProvider; import org.tarantool.SqlProtoUtils; -import org.tarantool.TarantoolClientConfig; -import org.tarantool.TarantoolClientImpl; +import org.tarantool.TarantoolClusterClient; +import org.tarantool.TarantoolClusterClientConfig; import org.tarantool.protocol.TarantoolPacket; import org.tarantool.util.JdbcConstants; +import org.tarantool.util.NodeSpec; import org.tarantool.util.SQLStates; import java.io.IOException; @@ -43,6 +44,7 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeoutException; import java.util.function.Function; +import java.util.stream.Collectors; /** * Tarantool {@link Connection} implementation. @@ -60,35 +62,65 @@ public class SQLConnection implements TarantoolConnection { private DatabaseMetaData cachedMetadata; private int resultSetHoldability = UNSET_HOLDABILITY; - public SQLConnection(String url, Properties properties) throws SQLException { - this.url = url; - this.properties = properties; + /** + * Creates a new connection to Tarantool server. + * + * @param originUrl raw URL string that was used to parse connection parameters + * @param properties extra parameters to configure a connection + * + * @deprecated use {@link #SQLConnection(String, List, Properties)} instead + */ + @Deprecated + public SQLConnection(String originUrl, Properties properties) throws SQLException { + this(originUrl, Collections.emptyList(), properties); + } + /** + * Creates a new connection to Tarantool server. + * + * @param originUrl raw URL string that was used to parse connection parameters + * @param nodes initial set of Tarantool nodes + * @param properties extra parameters to configure a connection + * + * @throws SQLException if any errors occur during the connecting + */ + public SQLConnection(String originUrl, + List nodes, + Properties properties) throws SQLException { + this.url = originUrl; + this.properties = properties; try { - client = makeSqlClient(makeAddress(properties), makeConfigFromProperties(properties)); + client = makeSqlClient(makeAddresses(nodes, properties), makeConfigFromProperties(properties)); } catch (Exception e) { throw new SQLException("Couldn't initiate connection using " + SQLDriver.diagProperties(properties), e); } } - protected SQLTarantoolClientImpl makeSqlClient(String address, TarantoolClientConfig config) { - return new SQLTarantoolClientImpl(address, config); + protected SQLTarantoolClientImpl makeSqlClient(List addresses, TarantoolClusterClientConfig config) { + return new SQLTarantoolClientImpl(addresses, config); } - private String makeAddress(Properties properties) throws SQLException { - String host = SQLProperty.HOST.getString(properties); - int port = SQLProperty.PORT.getInt(properties); - return host + ":" + port; + private List makeAddresses(List nodes, Properties properties) throws SQLException { + List addresses = nodes.stream() + .map(NodeSpec::toString) + .collect(Collectors.toList()); + if (addresses.isEmpty()) { + addresses.add(SQLProperty.HOST.getString(properties) + ":" + SQLProperty.PORT.getString(properties)); + } + return addresses; } - private TarantoolClientConfig makeConfigFromProperties(Properties properties) throws SQLException { - TarantoolClientConfig clientConfig = new TarantoolClientConfig(); + private TarantoolClusterClientConfig makeConfigFromProperties(Properties properties) throws SQLException { + TarantoolClusterClientConfig clientConfig = new TarantoolClusterClientConfig(); clientConfig.username = SQLProperty.USER.getString(properties); clientConfig.password = SQLProperty.PASSWORD.getString(properties); clientConfig.operationExpiryTimeMillis = SQLProperty.QUERY_TIMEOUT.getInt(properties); clientConfig.initTimeoutMillis = SQLProperty.LOGIN_TIMEOUT.getInt(properties); + clientConfig.clusterDiscoveryEntryFunction = SQLProperty.CLUSTER_DISCOVERY_ENTRY_FUNCTION.getString(properties); + clientConfig.clusterDiscoveryDelayMillis = SQLProperty.CLUSTER_DISCOVERY_DELAY_MILLIS.getInt(properties); + return clientConfig; } @@ -538,8 +570,8 @@ public SQLBatchResultHolder executeBatch(long timeout, List quer checkNotClosed(); SQLTarantoolClientImpl.SQLRawOps sqlOps = client.sqlRawOps(); SQLBatchResultHolder batchResult = useNetworkTimeout(timeout) - ? sqlOps.executeBatch(queries) - : sqlOps.executeBatch(timeout, queries); + ? sqlOps.executeBatch(queries) + : sqlOps.executeBatch(timeout, queries); return batchResult; } @@ -731,7 +763,7 @@ private static String formatError(SQLQueryHolder query) { return "Failed to execute SQL: " + query.getQuery() + ", params: " + query.getParams(); } - static class SQLTarantoolClientImpl extends TarantoolClientImpl { + static class SQLTarantoolClientImpl extends TarantoolClusterClient { private Future executeQuery(SQLQueryHolder queryHolder) { return exec(Code.EXECUTE, Key.SQL_TEXT, queryHolder.getQuery(), Key.SQL_BIND, queryHolder.getParams()); @@ -794,13 +826,13 @@ private SQLBatchResultHolder executeInternal(List queries, } }; - SQLTarantoolClientImpl(String address, TarantoolClientConfig config) { - super(address, config); + SQLTarantoolClientImpl(List addresses, TarantoolClusterClientConfig config) { + super(config, addresses); msgPackLite = SQLMsgPackLite.INSTANCE; } - SQLTarantoolClientImpl(SocketChannelProvider socketProvider, TarantoolClientConfig config) { - super(socketProvider, config); + SQLTarantoolClientImpl(SocketChannelProvider socketProvider, TarantoolClusterClientConfig config) { + super(config, socketProvider); msgPackLite = SQLMsgPackLite.INSTANCE; } diff --git a/src/main/java/org/tarantool/jdbc/SQLDriver.java b/src/main/java/org/tarantool/jdbc/SQLDriver.java index dbc89922..85245438 100644 --- a/src/main/java/org/tarantool/jdbc/SQLDriver.java +++ b/src/main/java/org/tarantool/jdbc/SQLDriver.java @@ -1,15 +1,19 @@ package org.tarantool.jdbc; import org.tarantool.SocketChannelProvider; -import org.tarantool.TarantoolClientConfig; +import org.tarantool.TarantoolClusterClientConfig; +import org.tarantool.util.NodeSpec; import org.tarantool.util.SQLStates; -import java.net.URI; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; import java.sql.Connection; import java.sql.Driver; import java.sql.DriverPropertyInfo; import java.sql.SQLException; import java.sql.SQLFeatureNotSupportedException; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.Properties; import java.util.concurrent.ConcurrentHashMap; @@ -17,6 +21,8 @@ public class SQLDriver implements Driver { + private static final String TARANTOOL_JDBC_SCHEME = "jdbc:tarantool:"; + static { try { java.sql.DriverManager.registerDriver(new SQLDriver()); @@ -29,84 +35,160 @@ public class SQLDriver implements Driver { @Override public Connection connect(String url, Properties info) throws SQLException { - if (url == null) { - throw new SQLException("Url must not be null"); - } if (!acceptsURL(url)) { return null; } - final URI uri = URI.create(url); - final Properties urlProperties = parseQueryString(uri, info); - String providerClassName = SQLProperty.SOCKET_CHANNEL_PROVIDER.getString(urlProperties); + final Properties urlProperties = parseConnectionString(url, info); - if (providerClassName == null) { - return new SQLConnection(url, urlProperties); + String[] hosts = SQLProperty.HOST.getString(urlProperties).split(","); + String[] ports = SQLProperty.PORT.getString(urlProperties).split(","); + List nodes = new ArrayList<>(hosts.length); + for (int i = 0; i < hosts.length; i++) { + nodes.add(new NodeSpec(hosts[i], Integer.valueOf(ports[i]))); } + String providerClassName = SQLProperty.SOCKET_CHANNEL_PROVIDER.getString(urlProperties); + if (providerClassName == null) { + return new SQLConnection(url, nodes, urlProperties); + } final SocketChannelProvider provider = getSocketProviderInstance(providerClassName); - - return new SQLConnection(url, urlProperties) { + return new SQLConnection(url, nodes, urlProperties) { @Override - protected SQLTarantoolClientImpl makeSqlClient(String address, TarantoolClientConfig config) { + protected SQLTarantoolClientImpl makeSqlClient(List addresses, + TarantoolClusterClientConfig config) { return new SQLTarantoolClientImpl(provider, config); } }; } - protected Properties parseQueryString(URI uri, Properties info) throws SQLException { + /** + * Parses an URL and to parameters and merges + * they with the parameters provided. If same + * parameter specified in both URL and properties + * the + * + *

+ * jdbc:tarantool://[user-info@][nodes][?parameters] + * user-info ::= user[:password] + * nodes ::= host1[:port1][,host2[:port2] ... ] + * parameters ::= param1=value1[¶m2=value2 ... ] + * + * @param url target URL string + * @param info extra properties to be merged + * + * @return merged properties + * + * @throws SQLException if any invalid parameters are passed + */ + protected Properties parseConnectionString(String url, Properties info) throws SQLException { Properties urlProperties = new Properties(); - // get scheme specific part (after the scheme part "jdbc:") - // to correct parse remaining URL - uri = URI.create(uri.getSchemeSpecificPart()); - String userInfo = uri.getUserInfo(); - if (userInfo != null) { - // Get user and password from the corresponding part of the URI, i.e. before @ sign. - int i = userInfo.indexOf(':'); - if (i < 0) { - SQLProperty.USER.setString(urlProperties, userInfo); - } else { - SQLProperty.USER.setString(urlProperties, userInfo.substring(0, i)); - SQLProperty.PASSWORD.setString(urlProperties, userInfo.substring(i + 1)); - } + String schemeSpecificPart = url.substring(TARANTOOL_JDBC_SCHEME.length()); + if (!schemeSpecificPart.startsWith("//")) { + throw new SQLException("Invalid URL: '//' is not presented."); } - if (uri.getQuery() != null) { - String[] parts = uri.getQuery().split("&"); - for (String part : parts) { - int i = part.indexOf("="); - if (i > -1) { - urlProperties.put(part.substring(0, i), part.substring(i + 1)); - } else { - urlProperties.put(part, ""); - } - } - } - if (uri.getHost() != null) { - // Default values are pre-put above. - urlProperties.setProperty(SQLProperty.HOST.getName(), uri.getHost()); + int userInfoEndPosition = schemeSpecificPart.indexOf('@'); + int queryStartPosition = schemeSpecificPart.indexOf('?'); + + if (userInfoEndPosition != -1) { + parseUserInfo(schemeSpecificPart.substring(2, userInfoEndPosition), urlProperties); } - if (uri.getPort() >= 0) { - // We need to convert port to string otherwise getProperty() will not see it. - urlProperties.setProperty(SQLProperty.PORT.getName(), String.valueOf(uri.getPort())); + if (queryStartPosition != -1) { + parseQueryParameters(schemeSpecificPart.substring(queryStartPosition + 1), urlProperties); } + String nodesPart = schemeSpecificPart.substring( + userInfoEndPosition == -1 ? 2 : userInfoEndPosition + 1, + queryStartPosition == -1 ? schemeSpecificPart.length() : queryStartPosition + ); + parseNodes(nodesPart, urlProperties); + if (info != null) { urlProperties.putAll(info); } - // Validate properties. - int port = SQLProperty.PORT.getInt(urlProperties); - if (port <= 0 || port > 65535) { - throw new SQLException("Port is out of range: " + port, SQLStates.INVALID_PARAMETER_VALUE.getSqlState()); + requirePortNumber(SQLProperty.PORT, urlProperties); + requireNonNegativeInteger(SQLProperty.LOGIN_TIMEOUT, urlProperties); + requireNonNegativeInteger(SQLProperty.QUERY_TIMEOUT, urlProperties); + requireNonNegativeInteger(SQLProperty.CLUSTER_DISCOVERY_DELAY_MILLIS, urlProperties); + + return urlProperties; + } + + private void parseUserInfo(String userInfo, Properties properties) { + int i = userInfo.indexOf(':'); + if (i < 0) { + SQLProperty.USER.setString(properties, userInfo); + } else { + SQLProperty.USER.setString(properties, userInfo.substring(0, i)); + SQLProperty.PASSWORD.setString(properties, userInfo.substring(i + 1)); + } + } + + private void parseNodes(String nodesPart, Properties properties) throws SQLException { + String[] nodes = nodesPart.split(","); + StringBuilder hosts = new StringBuilder(); + StringBuilder ports = new StringBuilder(); + for (String node : nodes) { + int portIndex = node.lastIndexOf(':'); + if (portIndex != -1 && node.lastIndexOf(']') < portIndex) { + String portString = node.substring(portIndex + 1); + hosts.append(node, 0, portIndex); + ports.append(portString); + } else { + hosts.append(node); + ports.append(SQLProperty.PORT.getDefaultValue()); + } + hosts.append(','); + ports.append(','); } - checkTimeout(SQLProperty.LOGIN_TIMEOUT, urlProperties); - checkTimeout(SQLProperty.QUERY_TIMEOUT, urlProperties); + hosts.setLength(hosts.length() - 1); + ports.setLength(ports.length() - 1); + SQLProperty.HOST.setString(properties, hosts.toString()); + SQLProperty.PORT.setString(properties, ports.toString()); + } - return urlProperties; + private void parseQueryParameters(String queryParams, Properties properties) throws SQLException { + String[] parts = queryParams.split("&"); + for (String part : parts) { + int i = part.indexOf("="); + if (i > -1) { + String name = part.substring(0, i); + String value = null; + try { + value = URLDecoder.decode(part.substring(i + 1), "UTF-8"); + } catch (UnsupportedEncodingException cause) { + throw new SQLException(cause.getMessage(), SQLStates.INVALID_PARAMETER_VALUE.getSqlState(), cause); + } + properties.put(name, value); + } else { + properties.put(part, ""); + } + } + } + + private void requirePortNumber(SQLProperty sqlProperty, Properties properties) throws SQLException { + String[] portList = sqlProperty.getString(properties).split(","); + for (String portString : portList) { + try { + int port = Integer.parseInt(portString); + if (port < 1 || port > 65535) { + throw new SQLException( + "Port is out of range: " + port, SQLStates.INVALID_PARAMETER_VALUE.getSqlState() + ); + } + } catch (NumberFormatException cause) { + throw new SQLException( + "Property " + sqlProperty.getName() + " must be a valid number.", + SQLStates.INVALID_PARAMETER_VALUE.getSqlState(), + cause + ); + } + } } - private void checkTimeout(SQLProperty sqlProperty, Properties properties) throws SQLException { + private void requireNonNegativeInteger(SQLProperty sqlProperty, Properties properties) throws SQLException { int timeout = sqlProperty.getInt(properties); if (timeout < 0) { throw new SQLException( @@ -143,30 +225,27 @@ protected SocketChannelProvider getSocketProviderInstance(String className) thro @Override public boolean acceptsURL(String url) throws SQLException { - return url.toLowerCase().startsWith("jdbc:tarantool:"); + if (url == null) { + throw new SQLException("Url must not be null"); + } + return url.startsWith(TARANTOOL_JDBC_SCHEME); } @Override public DriverPropertyInfo[] getPropertyInfo(String url, Properties info) throws SQLException { - try { - URI uri = new URI(url); - Properties properties = parseQueryString(uri, info); - - SQLProperty[] sqlProperties = SQLProperty.values(); - DriverPropertyInfo[] propertyInfoList = new DriverPropertyInfo[sqlProperties.length]; - for (int i = 0; i < sqlProperties.length; i++) { - SQLProperty sqlProperty = sqlProperties[i]; - String value = sqlProperty.getString(properties); - DriverPropertyInfo propertyInfo = new DriverPropertyInfo(sqlProperty.getName(), value); - propertyInfo.required = sqlProperty.isRequired(); - propertyInfo.description = sqlProperty.getDescription(); - propertyInfo.choices = sqlProperty.getChoices(); - propertyInfoList[i] = propertyInfo; - } - return propertyInfoList; - } catch (Exception e) { - throw new SQLException(e); + Properties properties = parseConnectionString(url, info); + SQLProperty[] sqlProperties = SQLProperty.values(); + DriverPropertyInfo[] propertyInfoList = new DriverPropertyInfo[sqlProperties.length]; + for (int i = 0; i < sqlProperties.length; i++) { + SQLProperty sqlProperty = sqlProperties[i]; + String value = sqlProperty.getString(properties); + DriverPropertyInfo propertyInfo = new DriverPropertyInfo(sqlProperty.getName(), value); + propertyInfo.required = sqlProperty.isRequired(); + propertyInfo.description = sqlProperty.getDescription(); + propertyInfo.choices = sqlProperty.getChoices(); + propertyInfoList[i] = propertyInfo; } + return propertyInfoList; } @Override diff --git a/src/main/java/org/tarantool/jdbc/SQLProperty.java b/src/main/java/org/tarantool/jdbc/SQLProperty.java index b147c221..e3444c38 100644 --- a/src/main/java/org/tarantool/jdbc/SQLProperty.java +++ b/src/main/java/org/tarantool/jdbc/SQLProperty.java @@ -8,21 +8,21 @@ public enum SQLProperty { HOST( "host", - "Tarantool server host", + "Tarantool server host (may be specified in the URL directly)", "localhost", null, true ), PORT( "port", - "Tarantool server port", + "Tarantool server port (may be specified in the URL directly)", "3301", null, true ), SOCKET_CHANNEL_PROVIDER( "socketChannelProvider", - "SocketProvider class implements org.tarantool.SocketChannelProvider", + "SocketProvider class that implements org.tarantool.SocketChannelProvider", null, null, false @@ -56,6 +56,22 @@ public enum SQLProperty { "0", null, false + ), + CLUSTER_DISCOVERY_ENTRY_FUNCTION( + "clusterDiscoveryEntryFunction", + "The name of the stored function to be used to fetch list of Tarantool instances." + + "It's unspecified by default.", + null, + null, + false + ), + CLUSTER_DISCOVERY_DELAY_MILLIS( + "clusterDiscoveryDelayMillis", + "A java.util.concurrent.Executor implementation that will be used to retry sending queries " + + "during a reconnaction. Default value is not specified.", + "60000", + null, + false ); private final String name; diff --git a/src/main/java/org/tarantool/jdbc/ds/SQLDataSource.java b/src/main/java/org/tarantool/jdbc/ds/SQLDataSource.java index 6a474d39..74fcab3a 100644 --- a/src/main/java/org/tarantool/jdbc/ds/SQLDataSource.java +++ b/src/main/java/org/tarantool/jdbc/ds/SQLDataSource.java @@ -3,12 +3,15 @@ import org.tarantool.jdbc.SQLConnection; import org.tarantool.jdbc.SQLConstant; import org.tarantool.jdbc.SQLProperty; +import org.tarantool.util.NodeSpec; import java.io.PrintWriter; import java.sql.Connection; import java.sql.SQLException; import java.sql.SQLFeatureNotSupportedException; import java.sql.SQLNonTransientException; +import java.util.Collections; +import java.util.List; import java.util.Properties; import java.util.concurrent.TimeUnit; import java.util.logging.Logger; @@ -26,7 +29,7 @@ public class SQLDataSource implements TarantoolDataSource, DataSource { @Override public Connection getConnection() throws SQLException { - return new SQLConnection(makeUrl(), new Properties(properties)); + return new SQLConnection(makeUrl(), makeNodeSpecs(), new Properties(properties)); } @Override @@ -34,7 +37,7 @@ public Connection getConnection(String username, String password) throws SQLExce Properties copyProperties = new Properties(properties); SQLProperty.USER.setString(copyProperties, username); SQLProperty.PASSWORD.setString(copyProperties, password); - return new SQLConnection(makeUrl(), copyProperties); + return new SQLConnection(makeUrl(), makeNodeSpecs(), copyProperties); } @Override @@ -156,4 +159,10 @@ private String makeUrl() { SQLProperty.HOST.getString(properties) + ":" + SQLProperty.PORT.getString(properties); } + private List makeNodeSpecs() throws SQLException { + return Collections.singletonList(new NodeSpec( + SQLProperty.HOST.getString(properties), + SQLProperty.PORT.getInt(properties) + )); + } } diff --git a/src/main/java/org/tarantool/util/NodeSpec.java b/src/main/java/org/tarantool/util/NodeSpec.java new file mode 100644 index 00000000..d902a18a --- /dev/null +++ b/src/main/java/org/tarantool/util/NodeSpec.java @@ -0,0 +1,27 @@ +package org.tarantool.util; + +/** + * Tarantool instance container. + */ +public class NodeSpec { + private final String host; + private final Integer port; + + public NodeSpec(String host, Integer port) { + this.host = host; + this.port = port; + } + + public String getHost() { + return host; + } + + public int getPort() { + return port; + } + + @Override + public String toString() { + return host + (port != null ? ":" + port : ""); + } +} diff --git a/src/test/java/org/tarantool/jdbc/JdbcConnectionTimeoutIT.java b/src/test/java/org/tarantool/jdbc/JdbcConnectionTimeoutIT.java index 7f4d2b3f..de5493fe 100644 --- a/src/test/java/org/tarantool/jdbc/JdbcConnectionTimeoutIT.java +++ b/src/test/java/org/tarantool/jdbc/JdbcConnectionTimeoutIT.java @@ -6,7 +6,7 @@ import static org.tarantool.TestAssumptions.assumeMinimalServerVersion; import org.tarantool.ServerVersion; -import org.tarantool.TarantoolClientConfig; +import org.tarantool.TarantoolClusterClientConfig; import org.tarantool.TarantoolTestHelper; import org.tarantool.protocol.TarantoolPacket; @@ -21,6 +21,8 @@ import java.sql.SQLException; import java.sql.SQLTimeoutException; import java.sql.Statement; +import java.util.Collections; +import java.util.List; import java.util.Properties; public class JdbcConnectionTimeoutIT { @@ -46,10 +48,11 @@ static void tearDownEnv() { @BeforeEach void setUp() throws SQLException { assumeMinimalServerVersion(testHelper.getInstanceVersion(), ServerVersion.V_2_1); - connection = new SQLConnection("", new Properties()) { + connection = new SQLConnection("", Collections.emptyList(), new Properties()) { @Override - protected SQLTarantoolClientImpl makeSqlClient(String address, TarantoolClientConfig config) { - return new SQLTarantoolClientImpl(address, config) { + protected SQLTarantoolClientImpl makeSqlClient(List addresses, + TarantoolClusterClientConfig config) { + return new SQLTarantoolClientImpl(addresses, config) { @Override protected void completeSql(TarantoolOp operation, TarantoolPacket pack) { try { diff --git a/src/test/java/org/tarantool/jdbc/JdbcDriverTest.java b/src/test/java/org/tarantool/jdbc/JdbcDriverTest.java index 5e7dce7a..5acaf03a 100644 --- a/src/test/java/org/tarantool/jdbc/JdbcDriverTest.java +++ b/src/test/java/org/tarantool/jdbc/JdbcDriverTest.java @@ -11,12 +11,12 @@ import org.tarantool.CommunicationException; import org.tarantool.SocketChannelProvider; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.function.Executable; import java.io.IOException; import java.net.ServerSocket; -import java.net.URI; import java.nio.channels.SocketChannel; import java.sql.Driver; import java.sql.DriverManager; @@ -26,21 +26,28 @@ public class JdbcDriverTest { + private SQLDriver driver; + + @BeforeEach + void setUp() { + driver = new SQLDriver(); + } + @Test public void testParseQueryString() throws Exception { - SQLDriver drv = new SQLDriver(); - Properties prop = new Properties(); SQLProperty.USER.setString(prop, "adm"); SQLProperty.PASSWORD.setString(prop, "secret"); - URI uri = new URI(String.format( + String uri = String.format( "jdbc:tarantool://server.local:3302?%s=%s&%s=%d", - SQLProperty.SOCKET_CHANNEL_PROVIDER.getName(), "some.class", - SQLProperty.QUERY_TIMEOUT.getName(), 5000) + SQLProperty.SOCKET_CHANNEL_PROVIDER.getName(), + "some.class", + SQLProperty.QUERY_TIMEOUT.getName(), + 5000 ); - Properties result = drv.parseQueryString(uri, prop); + Properties result = driver.parseConnectionString(uri, prop); assertNotNull(result); assertEquals("server.local", SQLProperty.HOST.getString(result)); @@ -53,8 +60,7 @@ public void testParseQueryString() throws Exception { @Test public void testParseQueryStringUserInfoInURI() throws Exception { - SQLDriver drv = new SQLDriver(); - Properties result = drv.parseQueryString(new URI("jdbc:tarantool://adm:secret@server.local"), null); + Properties result = driver.parseConnectionString("jdbc:tarantool://adm:secret@server.local", null); assertNotNull(result); assertEquals("server.local", SQLProperty.HOST.getString(result)); assertEquals("3301", SQLProperty.PORT.getString(result)); @@ -63,7 +69,29 @@ public void testParseQueryStringUserInfoInURI() throws Exception { } @Test - public void testParseQueryStringValidations() { + public void testParseWrongURL() throws Exception { + checkParseQueryStringValidation( + "jdbc:tarantool:adm:secret@server.local", + null, + "Invalid URL: '//' is not presented." + ); + } + + @Test + public void testParseMultiHostURI() throws Exception { + Properties result = driver.parseConnectionString( + "jdbc:tarantool://user:pwd@server.local,localhost:3301,192.168.0.1:3302", + null + ); + assertNotNull(result); + assertEquals("server.local,localhost,192.168.0.1", SQLProperty.HOST.getString(result)); + assertEquals("3301,3301,3302", SQLProperty.PORT.getString(result)); + assertEquals("user", SQLProperty.USER.getString(result)); + assertEquals("pwd", SQLProperty.PASSWORD.getString(result)); + } + + @Test + public void testParseWrongPort() { // Check non-number port checkParseQueryStringValidation("jdbc:tarantool://0", new Properties() { @@ -78,43 +106,35 @@ public void testParseQueryStringValidations() { // Check high port checkParseQueryStringValidation("jdbc:tarantool://0:65536", null, "Port is out of range: 65536"); + } - // Check non-number init timeout - checkParseQueryStringValidation( - String.format("jdbc:tarantool://0:3301?%s=nan", SQLProperty.LOGIN_TIMEOUT.getName()), - null, - "Property loginTimeout must be a valid number." - ); - - // Check negative init timeout - checkParseQueryStringValidation( - String.format("jdbc:tarantool://0:3301?%s=-100", SQLProperty.LOGIN_TIMEOUT.getName()), - null, - "Property loginTimeout must not be negative." - ); - - // Check non-number operation timeout - checkParseQueryStringValidation( - String.format("jdbc:tarantool://0:3301?%s=nan", SQLProperty.QUERY_TIMEOUT.getName()), - null, - "Property queryTimeout must be a valid number." - ); - - // Check negative operation timeout - checkParseQueryStringValidation( - String.format("jdbc:tarantool://0:3301?%s=-100", SQLProperty.QUERY_TIMEOUT.getName()), - null, - "Property queryTimeout must not be negative." - ); + @Test + void testParseInvalidNumbers() { + SQLProperty[] properties = { + SQLProperty.LOGIN_TIMEOUT, + SQLProperty.QUERY_TIMEOUT, + SQLProperty.CLUSTER_DISCOVERY_DELAY_MILLIS + }; + for (SQLProperty property : properties) { + checkParseQueryStringValidation( + String.format("jdbc:tarantool://0:3301?%s=nan", property.getName()), + null, + String.format("Property %s must be a valid number.", property.getName()) + ); + checkParseQueryStringValidation( + String.format("jdbc:tarantool://0:3301?%s=-100", property.getName()), + null, + String.format("Property %s must not be negative.", property.getName()) + ); + } } @Test public void testGetPropertyInfo() throws SQLException { - Driver drv = new SQLDriver(); Properties props = new Properties(); - DriverPropertyInfo[] info = drv.getPropertyInfo("jdbc:tarantool://server.local:3302", props); + DriverPropertyInfo[] info = driver.getPropertyInfo("jdbc:tarantool://server.local:3302", props); assertNotNull(info); - assertEquals(7, info.length); + assertEquals(9, info.length); for (DriverPropertyInfo e : info) { assertNotNull(e.name); @@ -142,6 +162,12 @@ public void testGetPropertyInfo() throws SQLException { } else if (SQLProperty.QUERY_TIMEOUT.getName().equals(e.name)) { assertFalse(e.required); assertEquals("0", e.value); + } else if (SQLProperty.CLUSTER_DISCOVERY_ENTRY_FUNCTION.getName().equals(e.name)) { + assertFalse(e.required); + assertNull(e.value); + } else if (SQLProperty.CLUSTER_DISCOVERY_DELAY_MILLIS.getName().equals(e.name)) { + assertFalse(e.required); + assertEquals("60000", e.value); } else { fail("Unknown property '" + e.name + "'"); } @@ -184,6 +210,14 @@ public void execute() throws Throwable { } } + @Test + void testAcceptUrl() throws SQLException { + assertFalse(driver.acceptsURL("http://localhost")); + assertFalse(driver.acceptsURL("jdbc:mysql://host1/myDb")); + assertTrue(driver.acceptsURL("jdbc:tarantool://localhost:3301")); + assertThrows(SQLException.class, () -> driver.acceptsURL(null)); + } + private void checkCustomSocketProviderFail(String providerClassName, String errMsg) throws SQLException { final Driver drv = DriverManager.getDriver("jdbc:tarantool:"); final Properties prop = new Properties(); @@ -194,15 +228,9 @@ private void checkCustomSocketProviderFail(String providerClassName, String errM assertTrue(e.getMessage().startsWith(errMsg), e.getMessage()); } - private void checkParseQueryStringValidation(final String uri, final Properties prop, String errMsg) { - final SQLDriver drv = new SQLDriver(); - SQLException e = assertThrows(SQLException.class, new Executable() { - @Override - public void execute() throws Throwable { - drv.parseQueryString(new URI(uri), prop); - } - }); - assertTrue(e.getMessage().startsWith(errMsg), e.getMessage()); + private void checkParseQueryStringValidation(final String uri, final Properties properties, String error) { + SQLException e = assertThrows(SQLException.class, () -> driver.parseConnectionString(uri, properties)); + assertTrue(e.getMessage().startsWith(error), e.getMessage()); } static class TestSQLProviderThatReturnsNull implements SocketChannelProvider { diff --git a/src/test/java/org/tarantool/jdbc/JdbcExceptionHandlingTest.java b/src/test/java/org/tarantool/jdbc/JdbcExceptionHandlingTest.java index f6a37ff4..10a67916 100644 --- a/src/test/java/org/tarantool/jdbc/JdbcExceptionHandlingTest.java +++ b/src/test/java/org/tarantool/jdbc/JdbcExceptionHandlingTest.java @@ -19,8 +19,8 @@ import static org.tarantool.jdbc.SQLDatabaseMetadata._VSPACE; import org.tarantool.CommunicationException; -import org.tarantool.TarantoolClientConfig; import org.tarantool.TarantoolClientOps; +import org.tarantool.TarantoolClusterClientConfig; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.function.Executable; @@ -227,9 +227,10 @@ private SQLConnection buildTestSQLConnection(SQLTarantoolClientImpl client, String url, Properties properties) throws SQLException { - return new SQLConnection(url, properties) { + return new SQLConnection(url, Collections.emptyList(), properties) { @Override - protected SQLTarantoolClientImpl makeSqlClient(String address, TarantoolClientConfig config) { + protected SQLTarantoolClientImpl makeSqlClient(List addresses, + TarantoolClusterClientConfig config) { return client; } }; diff --git a/src/test/java/org/tarantool/jdbc/JdbcFailOverConnectionIT.java b/src/test/java/org/tarantool/jdbc/JdbcFailOverConnectionIT.java new file mode 100644 index 00000000..94bb0a98 --- /dev/null +++ b/src/test/java/org/tarantool/jdbc/JdbcFailOverConnectionIT.java @@ -0,0 +1,140 @@ +package org.tarantool.jdbc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.tarantool.TestAssumptions.assumeMinimalServerVersion; +import static org.tarantool.TestUtils.makeDiscoveryFunction; +import static org.tarantool.jdbc.SqlTestUtils.makeDefaulJdbcUrl; + +import org.tarantool.ServerVersion; +import org.tarantool.TarantoolTestHelper; +import org.tarantool.TestUtils; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Extends the {@link org.tarantool.ClientReconnectClusterIT} test suite. + */ +@DisplayName("A JDBC clustered connection") +public class JdbcFailOverConnectionIT { + + private static String REPLICATION_CONFIG = TestUtils.makeReplicationString( + TarantoolTestHelper.USERNAME, + TarantoolTestHelper.PASSWORD, + "localhost:3401", + "localhost:3402" + ); + + private static final String[] BEFORE_SQL = { + "DROP TABLE IF EXISTS countries", + "CREATE TABLE countries(id INT PRIMARY KEY, name VARCHAR(100))", + "INSERT INTO countries VALUES (67, 'Greece')", + "INSERT INTO countries VALUES (77, 'Iceland')", + }; + + private static TarantoolTestHelper primaryNode; + private static TarantoolTestHelper secondaryNode; + + private Connection connection; + + @BeforeAll + public static void setupEnv() { + primaryNode = new TarantoolTestHelper("sql-replica1-it"); + secondaryNode = new TarantoolTestHelper("sql-replica2-it"); + primaryNode.createInstance( + TarantoolTestHelper.LUA_FILE, + 3401, + 3501, + REPLICATION_CONFIG, + 0.1 + ); + secondaryNode.createInstance( + TarantoolTestHelper.LUA_FILE, + 3402, + 3502, + REPLICATION_CONFIG, + 0.1 + ); + } + + @BeforeEach + public void setUpTest() { + primaryNode.startInstanceAsync(); + secondaryNode.startInstanceAsync(); + primaryNode.awaitStart(); + secondaryNode.awaitStart(); + + primaryNode.executeSql(BEFORE_SQL); + } + + @AfterEach + public void tearDownTest() throws SQLException { + if (connection != null) { + connection.close(); + } + primaryNode.stopInstance(); + secondaryNode.stopInstance(); + } + + @Test + public void testQueryFailOver() throws SQLException { + assumeMinimalServerVersion(primaryNode.getInstanceVersion(), ServerVersion.V_2_1); + connection = DriverManager.getConnection( + makeDefaulJdbcUrl("localhost:3401,localhost:3402", Collections.emptyMap()) + ); + assertFalse(connection.isClosed()); + + checkSynchronized(connection); + primaryNode.stopInstance(); + checkSynchronized(connection); + secondaryNode.stopInstance(); + assertTrue(connection.isClosed()); + } + + @Test + public void testRefreshNodes() throws SQLException { + assumeMinimalServerVersion(primaryNode.getInstanceVersion(), ServerVersion.V_2_1); + primaryNode.executeLua( + makeDiscoveryFunction("fetchNodes", Arrays.asList("localhost:3401", "localhost:3402")) + ); + + Map parameters = new HashMap<>(); + parameters.put(SQLProperty.CLUSTER_DISCOVERY_ENTRY_FUNCTION.getName(), "fetchNodes"); + connection = DriverManager.getConnection(makeDefaulJdbcUrl("localhost:3401", parameters)); + assertFalse(connection.isClosed()); + + checkSynchronized(connection); + primaryNode.stopInstance(); + checkSynchronized(connection); + secondaryNode.stopInstance(); + assertTrue(connection.isClosed()); + } + + private void checkSynchronized(Connection connection) throws SQLException { + try ( + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT * FROM countries"); + ) { + resultSet.next(); + assertEquals("Greece", resultSet.getString("name")); + resultSet.next(); + assertEquals("Iceland", resultSet.getString("name")); + } + } +} + diff --git a/src/test/java/org/tarantool/jdbc/SqlTestUtils.java b/src/test/java/org/tarantool/jdbc/SqlTestUtils.java index 8e915ee4..eb5edd72 100644 --- a/src/test/java/org/tarantool/jdbc/SqlTestUtils.java +++ b/src/test/java/org/tarantool/jdbc/SqlTestUtils.java @@ -1,22 +1,43 @@ package org.tarantool.jdbc; +import org.tarantool.TarantoolTestHelper; + import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; public class SqlTestUtils { public static String makeDefaultJdbcUrl() { + Map parameters = new HashMap<>(); + parameters.put(SQLProperty.USER.getName(), TarantoolTestHelper.USERNAME); + parameters.put(SQLProperty.PASSWORD.getName(), TarantoolTestHelper.PASSWORD); return makeJdbcUrl( - System.getProperty("tntHost", "localhost"), - Integer.valueOf(System.getProperty("tntPort", "3301")), - System.getProperty("tntUser", "test_admin"), - System.getProperty("tntPass", "4pWBZmLEgkmKK5WP") + TarantoolTestHelper.HOST + ":" + TarantoolTestHelper.PORT, + parameters ); } - public static String makeJdbcUrl(String host, int port, String user, String password) { - return String.format("jdbc:tarantool://%s:%d?user=%s&password=%s", host, port, user, password); + public static String makeDefaulJdbcUrl(String address, Map parameters) { + Map params = new HashMap<>(parameters); + params.put(SQLProperty.USER.getName(), TarantoolTestHelper.USERNAME); + params.put(SQLProperty.PASSWORD.getName(), TarantoolTestHelper.PASSWORD); + return makeJdbcUrl(address, params); + } + + public static String makeJdbcUrl(String address, Map parameters) { + StringBuilder url = new StringBuilder("jdbc:tarantool://"); + url.append(address).append("?"); + parameters.forEach((k, v) -> { + url.append(k) + .append("=") + .append(v) + .append("&"); + }); + url.setLength(url.length() - 1); + return url.toString(); } public static List> makeSingletonListResult(Object... rows) {