From 4ec4d3b4b6bf42a5c9f3d5b2e6a3743ef7df5506 Mon Sep 17 00:00:00 2001 From: Jeff Wasty Date: Mon, 23 Sep 2024 14:20:28 -0700 Subject: [PATCH] Configurable Retry Logic I - Statement Retry (#2396) * Trying on a new branch b/c I keep getting build failures and I have no idea why... * Adding back retryExec * More * Missing null check * Next to final * Removed mssql-jdbc.properties * Set up should start fresh + remove passwords to pass on pipeline * Minor cleanup * Minor cleanup * Another missing null check * Fix for timeout tests * Added timing tests + test comments * Formatting * Added a multiple rules test * Trying on a new branch b/c I keep getting build failures and I have no idea why... * More changes * Undo LimitEscapeTest changes * Remove redundant files * Final? * Remove mssql-jdpc.properties file * sync --> lock * Remove problematic test * Since error is unclear, try removing last test * Adding back connection test * I need debugging * Fix for MI * if condition for min time assertion * Leftover debug code, cleanup * Mistaken changes committed * More liberal time windows * Remove connection part * Missed some parts where connection retry was still included. * Forgot one more part * Added (most) PR comment revisions. * Add comments for specified and public facing methods * Added a missing test * More tests * Added more missing tests * Resolve retryCount test failure * Remove eaten exceptions * Removed the file not found exception as we read for file in all cases, not just when using CRL * Added a proper file read * Delete mssql-jdbc.properties * Added more coverage and minor fixes, ready for review again * Fixed read file test * Addressed recent pr comments * Remove double locking * Remove unneeded variable * Revisions after PR review * PR review update * Rename R_AKVURLInvalid as its use is no longer AKV specific * Add back logging * Typo * Removed unneeded comment * Make static variables thread-safe * Timing * JavaDoc cleanup. --- .../jdbc/ConfigurableRetryLogic.java | 292 +++++++++++ .../sqlserver/jdbc/ConfigurableRetryRule.java | 262 ++++++++++ .../sqlserver/jdbc/ISQLServerDataSource.java | 16 + ...ColumnEncryptionAzureKeyVaultProvider.java | 2 +- .../sqlserver/jdbc/SQLServerConnection.java | 31 ++ .../sqlserver/jdbc/SQLServerDataSource.java | 10 + .../sqlserver/jdbc/SQLServerDriver.java | 5 +- .../jdbc/SQLServerPreparedStatement.java | 1 + .../sqlserver/jdbc/SQLServerResource.java | 6 +- .../sqlserver/jdbc/SQLServerStatement.java | 74 ++- .../JDBCEncryptionDecryptionTest.java | 2 +- .../jdbc/SQLServerConnectionTest.java | 4 +- .../ConfigurableRetryLogicTest.java | 472 ++++++++++++++++++ .../RequestBoundaryMethodsTest.java | 2 + 14 files changed, 1160 insertions(+), 19 deletions(-) create mode 100644 src/main/java/com/microsoft/sqlserver/jdbc/ConfigurableRetryLogic.java create mode 100644 src/main/java/com/microsoft/sqlserver/jdbc/ConfigurableRetryRule.java create mode 100644 src/test/java/com/microsoft/sqlserver/jdbc/configurableretry/ConfigurableRetryLogicTest.java diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/ConfigurableRetryLogic.java b/src/main/java/com/microsoft/sqlserver/jdbc/ConfigurableRetryLogic.java new file mode 100644 index 000000000..ed5591034 --- /dev/null +++ b/src/main/java/com/microsoft/sqlserver/jdbc/ConfigurableRetryLogic.java @@ -0,0 +1,292 @@ +/* + * Microsoft JDBC Driver for SQL Server Copyright(c) Microsoft Corporation All rights reserved. This program is made + * available under the terms of the MIT License. See the LICENSE file in the project root for more information. + */ + +package com.microsoft.sqlserver.jdbc; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileReader; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.text.MessageFormat; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + + +/** + * Allows configurable statement retry through the use of the 'retryExec' connection property. Each rule read in is + * converted to ConfigRetryRule objects, which are stored and referenced during statement retry. + */ +public class ConfigurableRetryLogic { + private static final int INTERVAL_BETWEEN_READS_IN_MS = 30000; + private static final String DEFAULT_PROPS_FILE = "mssql-jdbc.properties"; + private static final Lock CRL_LOCK = new ReentrantLock(); + private static final java.util.logging.Logger CONFIGURABLE_RETRY_LOGGER = java.util.logging.Logger + .getLogger("com.microsoft.sqlserver.jdbc.ConfigurableRetryLogic"); + private static final String SEMI_COLON = ";"; + private static final String COMMA = ","; + private static final String FORWARD_SLASH = "/"; + private static final String EQUALS_SIGN = "="; + private static final String RETRY_EXEC = "retryExec"; + /** + * The time the properties file was last modified. + */ + private static final AtomicLong timeLastModified = new AtomicLong(0); + /** + * The time we last read the properties file. + */ + private static final AtomicLong timeLastRead = new AtomicLong(0); + /** + * The last query executed (used when rule is process-dependent). + */ + private static final AtomicReference lastQuery = new AtomicReference<>(""); + /** + * The previously read rules from the connection string. + */ + private static final AtomicReference prevRulesFromConnectionString = new AtomicReference<>(""); + /** + * The list of statement retry rules. + */ + private static final AtomicReference> stmtRules = new AtomicReference<>( + new HashMap<>()); + private static ConfigurableRetryLogic singleInstance; + + /** + * Constructs the ConfigurableRetryLogic object reading rules from available sources. + * + * @throws SQLServerException + * if unable to construct + */ + private ConfigurableRetryLogic() throws SQLServerException { + timeLastRead.compareAndSet(0, new Date().getTime()); + setUpRules(null); + } + + /** + * Fetches the static instance of ConfigurableRetryLogic, instantiating it if it hasn't already been. Each time the + * instance is fetched, we check if a re-read is needed, and do so if properties should be re-read. + * + * @return the static instance of ConfigurableRetryLogic + * @throws SQLServerException + * an exception + */ + public static ConfigurableRetryLogic getInstance() throws SQLServerException { + if (singleInstance == null) { + CRL_LOCK.lock(); + try { + if (singleInstance == null) { + singleInstance = new ConfigurableRetryLogic(); + } else { + refreshRuleSet(); + } + } finally { + CRL_LOCK.unlock(); + } + } else { + refreshRuleSet(); + } + + return singleInstance; + } + + /** + * If it has been INTERVAL_BETWEEN_READS_IN_MS (30 secs) since last read, see if we last did a file read, if so + * only reread if the file has been modified. If no file read, set up rules using the prev. connection string rules. + * + * @throws SQLServerException + * when an exception occurs + */ + private static void refreshRuleSet() throws SQLServerException { + long currentTime = new Date().getTime(); + + if ((currentTime - timeLastRead.get()) >= INTERVAL_BETWEEN_READS_IN_MS) { + timeLastRead.set(currentTime); + if (timeLastModified.get() != 0) { + // If timeLastModified is set, we previously read from file, so we setUpRules also reading from file + File f = new File(getCurrentClassPath()); + if (f.lastModified() != timeLastModified.get()) { + setUpRules(null); + } + } else { + setUpRules(prevRulesFromConnectionString.get()); + } + } + } + + /** + * Sets rules given from connection string. + * + * @param newRules + * the new rules to use + * @throws SQLServerException + * when an exception occurs + */ + void setFromConnectionString(String newRules) throws SQLServerException { + prevRulesFromConnectionString.set(newRules); + setUpRules(prevRulesFromConnectionString.get()); + } + + /** + * Stores last query executed. + * + * @param newQueryToStore + * the new query to store + */ + void storeLastQuery(String newQueryToStore) { + lastQuery.set(newQueryToStore.toLowerCase()); + } + + /** + * Gets last query. + * + * @return the last query + */ + String getLastQuery() { + return lastQuery.get(); + } + + /** + * Sets up rules based on either connection string option or file read. + * + * @param cxnStrRules + * if null, rules are constructed from file, else, this parameter is used to construct rules + * @throws SQLServerException + * if an exception occurs + */ + private static void setUpRules(String cxnStrRules) throws SQLServerException { + LinkedList temp; + + stmtRules.set(new HashMap<>()); + lastQuery.set(""); + + if (cxnStrRules == null || cxnStrRules.isEmpty()) { + temp = readFromFile(); + } else { + temp = new LinkedList<>(); + Collections.addAll(temp, cxnStrRules.split(SEMI_COLON)); + } + createRules(temp); + } + + /** + * Creates and stores rules based on the inputted list of rules. + * + * @param listOfRules + * the list of rules, as a String LinkedList + * @throws SQLServerException + * if unable to create rules from the inputted list + */ + private static void createRules(LinkedList listOfRules) throws SQLServerException { + stmtRules.set(new HashMap<>()); + + for (String potentialRule : listOfRules) { + ConfigurableRetryRule rule = new ConfigurableRetryRule(potentialRule); + + if (rule.getError().contains(COMMA)) { + String[] arr = rule.getError().split(COMMA); + + for (String retryError : arr) { + ConfigurableRetryRule splitRule = new ConfigurableRetryRule(retryError, rule); + stmtRules.get().put(Integer.parseInt(splitRule.getError()), splitRule); + } + } else { + stmtRules.get().put(Integer.parseInt(rule.getError()), rule); + } + } + } + + /** + * Gets the current class path (for use in file reading). + * + * @return the current class path, as a String + * @throws SQLServerException + * if unable to retrieve the current class path + */ + private static String getCurrentClassPath() throws SQLServerException { + String location = ""; + String className = ""; + + try { + className = new Object() {}.getClass().getEnclosingClass().getName(); + location = Class.forName(className).getProtectionDomain().getCodeSource().getLocation().getPath(); + location = location.substring(0, location.length() - 16); + URI uri = new URI(location + FORWARD_SLASH); + return uri.getPath() + DEFAULT_PROPS_FILE; // For now, we only allow "mssql-jdbc.properties" as file name. + } catch (URISyntaxException e) { + MessageFormat form = new MessageFormat(SQLServerException.getErrString("R_URLInvalid")); + Object[] msgArgs = {location + FORWARD_SLASH}; + throw new SQLServerException(form.format(msgArgs), null, 0, e); + } catch (ClassNotFoundException e) { + MessageFormat form = new MessageFormat(SQLServerException.getErrString("R_UnableToFindClass")); + Object[] msgArgs = {className}; + throw new SQLServerException(form.format(msgArgs), null, 0, e); + } + } + + /** + * Attempts to read rules from the properties file. + * + * @return the list of rules as a LinkedList + * @throws SQLServerException + * if unable to read from the file + */ + private static LinkedList readFromFile() throws SQLServerException { + String filePath = getCurrentClassPath(); + LinkedList list = new LinkedList<>(); + + try { + File f = new File(filePath); + try (BufferedReader buffer = new BufferedReader(new FileReader(f))) { + String readLine; + while ((readLine = buffer.readLine()) != null) { + if (readLine.startsWith(RETRY_EXEC)) { + String value = readLine.split(EQUALS_SIGN)[1]; + Collections.addAll(list, value.split(SEMI_COLON)); + } + } + } + timeLastModified.set(f.lastModified()); + } catch (FileNotFoundException e) { + // If the file is not found either A) We're not using CRL OR B) the path is wrong. Do not error out, instead + // log a message. + if (CONFIGURABLE_RETRY_LOGGER.isLoggable(java.util.logging.Level.FINER)) { + CONFIGURABLE_RETRY_LOGGER.finest("File not found at path - \"" + filePath + "\""); + } + } catch (IOException e) { + MessageFormat form = new MessageFormat(SQLServerException.getErrString("R_errorReadingStream")); + Object[] msgArgs = {e.getMessage() + ", from path - \"" + filePath + "\""}; + throw new SQLServerException(form.format(msgArgs), null, 0, e); + } + return list; + } + + /** + * Searches rule set for the given rule. + * + * @param ruleToSearchFor + * the rule to search for + * @return the configurable retry rule + * @throws SQLServerException + * when an exception occurs + */ + ConfigurableRetryRule searchRuleSet(int ruleToSearchFor) throws SQLServerException { + refreshRuleSet(); + for (Map.Entry entry : stmtRules.get().entrySet()) { + if (entry.getKey() == ruleToSearchFor) { + return entry.getValue(); + } + } + return null; + } +} diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/ConfigurableRetryRule.java b/src/main/java/com/microsoft/sqlserver/jdbc/ConfigurableRetryRule.java new file mode 100644 index 000000000..f52df8d8d --- /dev/null +++ b/src/main/java/com/microsoft/sqlserver/jdbc/ConfigurableRetryRule.java @@ -0,0 +1,262 @@ +/* + * Microsoft JDBC Driver for SQL Server Copyright(c) Microsoft Corporation All rights reserved. This program is made + * available under the terms of the MIT License. See the LICENSE file in the project root for more information. + */ + +package com.microsoft.sqlserver.jdbc; + +import java.text.MessageFormat; +import java.util.ArrayList; + + +/** + * The ConfigRetryRule object is what is used by the ConfigurableRetryLogic class to handle statement retries. Each + * ConfigRetryRule object allows for one rule. + * + */ +class ConfigurableRetryRule { + private final String PLUS_SIGN = "+"; + private final String MULTIPLICATION_SIGN = "*"; + private final String COMMA = ","; + private final String ZERO = "0"; + private String operand = "+"; + private int initialRetryTime = 0; + private int retryChange = 2; + private int retryCount = 1; + private String retryQueries = ""; + private String retryError; + + private ArrayList waitTimes = new ArrayList<>(); + + /** + * Construct a ConfigurableRetryRule object from a String rule. + * + * @param rule + * the rule used to construct the ConfigRetryRule object + * @throws SQLServerException + * if there is a problem parsing the rule + */ + ConfigurableRetryRule(String rule) throws SQLServerException { + addElements(removeExtraElementsAndSplitRule(rule)); + calculateWaitTimes(); + } + + /** + * Allows constructing a ConfigRetryRule object from another ConfigRetryRule object. Used when the first object has + * multiple errors provided. We pass in the multi-error object and create 1 new object for each error in the initial + * object. + * + * @param newRule + * the rule used to construct the ConfigRetryRule object + * @param baseRule + * the ConfigRetryRule object to base the new objects off of + */ + ConfigurableRetryRule(String newRule, ConfigurableRetryRule baseRule) { + copyFromRule(baseRule); + this.retryError = newRule; + } + + /** + * Copy elements from the base rule to this rule. + * + * @param baseRule + * the rule to copy elements from + */ + private void copyFromRule(ConfigurableRetryRule baseRule) { + this.retryError = baseRule.retryError; + this.operand = baseRule.operand; + this.initialRetryTime = baseRule.initialRetryTime; + this.retryChange = baseRule.retryChange; + this.retryCount = baseRule.retryCount; + this.retryQueries = baseRule.retryQueries; + this.waitTimes = baseRule.waitTimes; + } + + /** + * Removes extra elements in the rule (e.g. '{') and splits the rule based on ':' (colon). + * + * @param rule + * the rule to format and split + * @return the split rule as a string array + */ + private String[] removeExtraElementsAndSplitRule(String rule) { + if (rule.endsWith(":")) { + rule = rule + ZERO; // Add a zero to make below parsing easier + } + + rule = rule.replace("{", ""); + rule = rule.replace("}", ""); + rule = rule.trim(); + + return rule.split(":"); // Split on colon + } + + /** + * Checks if the value passed in is numeric. In the case where the value contains a comma, the value must be a + * multi-error value, e.g. 2714,2716. This must be separated, and each error checked separately. + * + * @param value + * the value to be checked + * @throws SQLServerException + * if a non-numeric value is passed in + */ + private void checkParameter(String value) throws SQLServerException { + if (!StringUtils.isNumeric(value)) { + String[] arr = value.split(COMMA); + for (String error : arr) { + if (!StringUtils.isNumeric(error)) { + MessageFormat form = new MessageFormat(SQLServerException.getErrString("R_invalidParameterNumber")); + Object[] msgArgs = {error}; + throw new SQLServerException(null, form.format(msgArgs), null, 0, true); + } + } + } + } + + /** + * Parses the passed in string array, containing all elements from the original rule, and assigns the information + * to the class variables. The logic is as follows: + *

+ *

+ * The rule array, which was created by splitting the rule string based on ":", must be of length 2 or 3. If not + * there are too many parts, and an error is thrown. + *

+ *

+ * If it is of length 2 or 3, the first part is always the retry error (the error to retry on). We check if its + * numeric, and if so, assign it to the class variable. The second part are the retry timings, which include + * retry count (mandatory), initial retry time (optional), operand (optional), and retry change (optional). A + * parameter can only be included, if ALL parameters prior to it are included. Thus, these are the only valid rule + * formats for rules of length 2: + * error; count + * error; count, initial retry time + * error; count, initial retry time [OPERAND] + * error; count, initial retry time [OPERAND] retry change + *

+ *

+ * Next, the second part of the rule is parsed based on "," and each part checked. The retry count is mandatory + * and must be numeric and greater than 0, else an error is thrown. + *

+ *

+ * If there is a second part to the retry timings, it includes any of the parameters mentioned above: initial retry + * time, operand, and retry change. We first check if there is an operand, if not, then only initial retry time has + * been given, and it is assigned. If there is an operand, we split this second part based on the operand. + * Whatever was before the operand was the initial retry time, and if there was something after the operand, this + * is the retry change. If there are more than 2 parts to the timing, i.e. more than 2 commas, throw an error. + *

+ *

+ * Finally, if the rule has 3 parts, it includes a query specifier, parse this and assign it. + * + * @param rule + * the passed in rule, as a string array + * @throws SQLServerException + * if a rule or parameter has invalid inputs + */ + private void addElements(String[] rule) throws SQLServerException { + if (rule.length == 2 || rule.length == 3) { + checkParameter(rule[0]); + retryError = rule[0]; + String[] timings = rule[1].split(COMMA); + checkParameter(timings[0]); + retryCount = Integer.parseInt(timings[0]); + + if (timings.length == 2) { + if (timings[1].contains(MULTIPLICATION_SIGN)) { + String[] initialAndChange = timings[1].split("\\*"); + checkParameter(initialAndChange[0]); + + initialRetryTime = Integer.parseInt(initialAndChange[0]); + operand = MULTIPLICATION_SIGN; + if (initialAndChange.length > 1) { + checkParameter(initialAndChange[1]); + retryChange = Integer.parseInt(initialAndChange[1]); + } else { + retryChange = initialRetryTime; + } + } else if (timings[1].contains(PLUS_SIGN)) { + String[] initialAndChange = timings[1].split("\\+"); + checkParameter(initialAndChange[0]); + + initialRetryTime = Integer.parseInt(initialAndChange[0]); + operand = PLUS_SIGN; + if (initialAndChange.length > 1) { + checkParameter(initialAndChange[1]); + retryChange = Integer.parseInt(initialAndChange[1]); + } else { + retryChange = 2; + } + } else { + checkParameter(timings[1]); + initialRetryTime = Integer.parseInt(timings[1]); + } + } else if (timings.length > 2) { + MessageFormat form = new MessageFormat(SQLServerException.getErrString("R_invalidParameterNumber")); + Object[] msgArgs = {rule[1]}; + throw new SQLServerException(null, form.format(msgArgs), null, 0, true); + } + + if (rule.length == 3) { + retryQueries = (rule[2].equals(ZERO) ? "" : rule[2].toLowerCase()); + } + } else { + MessageFormat form = new MessageFormat(SQLServerException.getErrString("R_InvalidRuleFormat")); + Object[] msgArgs = {rule.length}; + throw new SQLServerException(null, form.format(msgArgs), null, 0, true); + } + } + + /** + * Calculates all the 'wait times', i.e. how long the driver waits between re-execution of statement, based + * on the parameters in the rule. Saves all these times in "waitTimes" to be referenced during statement re-execution. + */ + private void calculateWaitTimes() { + for (int i = 0; i < retryCount; ++i) { + int waitTime = initialRetryTime; + if (operand.equals(PLUS_SIGN)) { + for (int j = 0; j < i; ++j) { + waitTime += retryChange; + } + } else if (operand.equals(MULTIPLICATION_SIGN)) { + for (int k = 0; k < i; ++k) { + waitTime *= retryChange; + } + } + waitTimes.add(waitTime); + } + } + + /** + * Returns the retry error for this ConfigRetryRule object. + * + * @return the retry error + */ + String getError() { + return retryError; + } + + /** + * Returns the retry count (amount of times to retry) for this ConfigRetryRule object. + * + * @return the retry count + */ + int getRetryCount() { + return retryCount; + } + + /** + * Returns the retry query specifier for this ConfigRetryRule object. + * + * @return the retry query specifier + */ + String getRetryQueries() { + return retryQueries; + } + + /** + * Returns an array listing the waiting times between each retry, for this ConfigRetryRule object. + * + * @return the list of waiting times + */ + ArrayList getWaitTimes() { + return waitTimes; + } +} diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/ISQLServerDataSource.java b/src/main/java/com/microsoft/sqlserver/jdbc/ISQLServerDataSource.java index b9ab8027f..66fc33744 100644 --- a/src/main/java/com/microsoft/sqlserver/jdbc/ISQLServerDataSource.java +++ b/src/main/java/com/microsoft/sqlserver/jdbc/ISQLServerDataSource.java @@ -1346,6 +1346,22 @@ public interface ISQLServerDataSource extends javax.sql.CommonDataSource { * @return cacheBulkCopyMetadata boolean value */ boolean getcacheBulkCopyMetadata(); + + /** + * Returns value of 'retryExec' from Connection String. + * + * @param retryExec + * Set of rules used for statement (execution) retry + */ + void setRetryExec(String retryExec); + + /** + * Sets the value for 'retryExec' property + * + * @return retryExec + * String value + */ + String getRetryExec(); /** * useFlexibleCallableStatements is temporarily removed. This is meant as a no-op. diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerColumnEncryptionAzureKeyVaultProvider.java b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerColumnEncryptionAzureKeyVaultProvider.java index 42281688a..082028f2c 100644 --- a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerColumnEncryptionAzureKeyVaultProvider.java +++ b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerColumnEncryptionAzureKeyVaultProvider.java @@ -645,7 +645,7 @@ private void validateNonEmptyAKVPath(String masterKeyPath) throws SQLServerExcep } } } catch (URISyntaxException e) { - MessageFormat form = new MessageFormat(SQLServerException.getErrString("R_AKVURLInvalid")); + MessageFormat form = new MessageFormat(SQLServerException.getErrString("R_URLInvalid")); Object[] msgArgs = {masterKeyPath}; throw new SQLServerException(form.format(msgArgs), null, 0, e); } diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerConnection.java b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerConnection.java index d81e3da9e..903adc0b0 100644 --- a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerConnection.java +++ b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerConnection.java @@ -1064,6 +1064,28 @@ public void setCalcBigDecimalPrecision(boolean calcBigDecimalPrecision) { this.calcBigDecimalPrecision = calcBigDecimalPrecision; } + private String retryExec = SQLServerDriverStringProperty.RETRY_EXEC.getDefaultValue(); + + /** + * Returns the set of configurable statement retry rules set in retryExec + * + * @return + * A string containing statement retry rules. + */ + public String getRetryExec() { + return retryExec; + } + + /** + * Sets the list of configurable statement retry rules, for the given connection, in retryExec. + * + * @param retryExec + * The list of retry rules to set, as a string. + */ + public void setRetryExec(String retryExec) { + this.retryExec = retryExec; + } + /** Session Recovery Object */ private transient IdleConnectionResiliency sessionRecovery = new IdleConnectionResiliency(this); @@ -2338,6 +2360,15 @@ Connection connectInternal(Properties propsIn, IPAddressPreference.valueOfString(sPropValue).toString()); } + sPropKey = SQLServerDriverStringProperty.RETRY_EXEC.toString(); + sPropValue = activeConnectionProperties.getProperty(sPropKey); + if (null == sPropValue) { + sPropValue = SQLServerDriverStringProperty.RETRY_EXEC.getDefaultValue(); + activeConnectionProperties.setProperty(sPropKey, sPropValue); + } + retryExec = sPropValue; + ConfigurableRetryLogic.getInstance().setFromConnectionString(sPropValue); + sPropKey = SQLServerDriverBooleanProperty.CALC_BIG_DECIMAL_PRECISION.toString(); sPropValue = activeConnectionProperties.getProperty(sPropKey); if (null == sPropValue) { diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerDataSource.java b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerDataSource.java index e27845a0d..6136cffa0 100644 --- a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerDataSource.java +++ b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerDataSource.java @@ -1383,6 +1383,16 @@ public boolean getCalcBigDecimalPrecision() { SQLServerDriverBooleanProperty.CALC_BIG_DECIMAL_PRECISION.getDefaultValue()); } + @Override + public void setRetryExec(String retryExec) { + setStringProperty(connectionProps, SQLServerDriverStringProperty.RETRY_EXEC.toString(), retryExec); + } + + @Override + public String getRetryExec() { + return getStringProperty(connectionProps, SQLServerDriverStringProperty.RETRY_EXEC.toString(), null); + } + /** * Sets a property string value. * diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerDriver.java b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerDriver.java index 0e9689216..1c9abc000 100644 --- a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerDriver.java +++ b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerDriver.java @@ -610,7 +610,8 @@ enum SQLServerDriverStringProperty { ENCRYPT("encrypt", EncryptOption.TRUE.toString()), SERVER_CERTIFICATE("serverCertificate", ""), DATETIME_DATATYPE("datetimeParameterType", DatetimeType.DATETIME2.toString()), - ACCESS_TOKEN_CALLBACK_CLASS("accessTokenCallbackClass", ""); + ACCESS_TOKEN_CALLBACK_CLASS("accessTokenCallbackClass", ""), + RETRY_EXEC("retryExec", ""); private final String name; private final String defaultValue; @@ -852,6 +853,8 @@ public final class SQLServerDriver implements java.sql.Driver { SQLServerDriverObjectProperty.ACCESS_TOKEN_CALLBACK.getDefaultValue(), false, null), new SQLServerDriverPropertyInfo(SQLServerDriverStringProperty.ACCESS_TOKEN_CALLBACK_CLASS.toString(), SQLServerDriverStringProperty.ACCESS_TOKEN_CALLBACK_CLASS.getDefaultValue(), false, null), + new SQLServerDriverPropertyInfo(SQLServerDriverStringProperty.RETRY_EXEC.toString(), + SQLServerDriverStringProperty.RETRY_EXEC.getDefaultValue(), false, null), new SQLServerDriverPropertyInfo(SQLServerDriverBooleanProperty.REPLICATION.toString(), Boolean.toString(SQLServerDriverBooleanProperty.REPLICATION.getDefaultValue()), false, TRUE_FALSE), new SQLServerDriverPropertyInfo(SQLServerDriverBooleanProperty.SEND_TIME_AS_DATETIME.toString(), diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerPreparedStatement.java b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerPreparedStatement.java index a5f50b14a..2c2bbcb9a 100644 --- a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerPreparedStatement.java +++ b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerPreparedStatement.java @@ -567,6 +567,7 @@ public boolean execute() throws SQLServerException, SQLTimeoutException { loggerExternal.finer(toString() + ACTIVITY_ID + ActivityCorrelator.getCurrent().toString()); } checkClosed(); + ConfigurableRetryLogic.getInstance().storeLastQuery(this.userSQL); connection.unprepareUnreferencedPreparedStatementHandles(false); executeStatement(new PrepStmtExecCmd(this, EXECUTE)); loggerExternal.exiting(getClassNameLogging(), "execute", null != resultSet); diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerResource.java b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerResource.java index 64e140534..55a5a5fc0 100644 --- a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerResource.java +++ b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerResource.java @@ -370,8 +370,8 @@ protected Object[][] getContents() { {"R_ForceEncryptionTrue_HonorAEFalseRS", "Cannot set Force Encryption to true for parameter {0} because encryption is not enabled for the statement or procedure."}, {"R_ForceEncryptionTrue_HonorAETrue_UnencryptedColumnRS", "Cannot execute update because Force Encryption was set as true for parameter {0} and the database expects this parameter to be sent as plaintext. This may be due to a configuration error."}, {"R_NullValue", "{0} cannot be null."}, + {"R_URLInvalid", "Invalid URL specified: {0}."}, {"R_AKVPathNull", "Azure Key Vault key path cannot be null."}, - {"R_AKVURLInvalid", "Invalid URL specified: {0}."}, {"R_AKVMasterKeyPathInvalid", "Invalid Azure Key Vault key path specified: {0}."}, {"R_ManagedIdentityInitFail", "Failed to initialize package to get Managed Identity token for Azure Key Vault."}, {"R_EmptyCEK", "Empty column encryption key specified."}, @@ -517,6 +517,7 @@ protected Object[][] getContents() { {"R_InvalidCSVQuotes", "Failed to parse the CSV file, verify that the fields are correctly enclosed in double quotes."}, {"R_TokenRequireUrl", "Token credentials require a URL using the HTTPS protocol scheme."}, {"R_calcBigDecimalPrecisionPropertyDescription", "Indicates whether the driver should calculate precision for big decimal values."}, + {"R_retryExecPropertyDescription", "List of rules to follow for configurable retry logic."}, {"R_maxResultBufferPropertyDescription", "Determines maximum amount of bytes that can be read during retrieval of result set"}, {"R_maxResultBufferInvalidSyntax", "Invalid syntax: {0} in maxResultBuffer parameter."}, {"R_maxResultBufferNegativeParameterValue", "MaxResultBuffer must have positive value: {0}."}, @@ -545,6 +546,9 @@ protected Object[][] getContents() { {"R_InvalidSqlQuery", "Invalid SQL Query: {0}"}, {"R_InvalidScale", "Scale of input value is larger than the maximum allowed by SQL Server."}, {"R_colCountNotMatchColTypeCount", "Number of provided columns {0} does not match the column data types definition {1}."}, + {"R_InvalidRuleFormat", "Wrong number of parameters supplied to rule. Number of parameters: {0}, expected: 2 or 3."}, + {"R_InvalidRetryInterval", "Current retry interval: {0}, is longer than queryTimeout: {1}."}, + {"R_UnableToFindClass", "Unable to locate specified class: {0}"}, }; } // @formatter:on diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerStatement.java b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerStatement.java index b599856a7..883320640 100644 --- a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerStatement.java +++ b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerStatement.java @@ -21,6 +21,7 @@ import java.util.Stack; import java.util.StringTokenizer; import java.util.Vector; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; @@ -241,22 +242,66 @@ final void executeStatement(TDSCommand newStmtCmd) throws SQLServerException, SQ execProps = new ExecuteProperties(this); - try { - // (Re)execute this Statement with the new command - executeCommand(newStmtCmd); - } catch (SQLServerException e) { - if (e.getDriverErrorCode() == SQLServerException.ERROR_QUERY_TIMEOUT) { - if (e.getCause() == null) { - throw new SQLTimeoutException(e.getMessage(), e.getSQLState(), e.getErrorCode(), e); + boolean cont; + int retryAttempt = 0; + ConfigurableRetryLogic crl = ConfigurableRetryLogic.getInstance(); + + do { + cont = false; + try { + // (Re)execute this Statement with the new command + executeCommand(newStmtCmd); + } catch (SQLServerException e) { + SQLServerError sqlServerError = e.getSQLServerError(); + ConfigurableRetryRule rule = null; + + if (null != sqlServerError) { + rule = crl.searchRuleSet(e.getSQLServerError().getErrorNumber()); + } + + // If there is a rule for this error AND we still have retries remaining THEN we can proceed, otherwise + // first check for query timeout, and then throw the error if queryTimeout was not reached + if (null != rule && retryAttempt < rule.getRetryCount()) { + + // Also check if the last executed statement matches the query constraint passed in for the rule. + // Defaults to true, changed to false if the query does NOT match. + boolean matchesDefinedQuery = true; + if (!(rule.getRetryQueries().isEmpty())) { + + matchesDefinedQuery = rule.getRetryQueries().contains(crl.getLastQuery().split(" ")[0]); + } + + if (matchesDefinedQuery) { + int timeToWait = rule.getWaitTimes().get(retryAttempt); + int queryTimeout = connection.getQueryTimeoutSeconds(); + if (queryTimeout >= 0 && timeToWait > queryTimeout) { + MessageFormat form = new MessageFormat( + SQLServerException.getErrString("R_InvalidRetryInterval")); + Object[] msgArgs = {timeToWait, queryTimeout}; + throw new SQLServerException(null, form.format(msgArgs), null, 0, true); + } + try { + Thread.sleep(TimeUnit.SECONDS.toMillis(timeToWait)); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + cont = true; + retryAttempt++; + } + } else if (e.getDriverErrorCode() == SQLServerException.ERROR_QUERY_TIMEOUT) { + if (e.getCause() == null) { + throw new SQLTimeoutException(e.getMessage(), e.getSQLState(), e.getErrorCode(), e); + } + throw new SQLTimeoutException(e.getMessage(), e.getSQLState(), e.getErrorCode(), e.getCause()); + } else { + throw e; + } + } finally { + if (newStmtCmd.wasExecuted()) { + lastStmtExecCmd = newStmtCmd; } - throw new SQLTimeoutException(e.getMessage(), e.getSQLState(), e.getErrorCode(), e.getCause()); - } else { - throw e; } - } finally { - if (newStmtCmd.wasExecuted()) - lastStmtExecCmd = newStmtCmd; - } + } while (cont); } /** @@ -793,6 +838,7 @@ public boolean execute(String sql) throws SQLServerException, SQLTimeoutExceptio loggerExternal.finer(toString() + ACTIVITY_ID + ActivityCorrelator.getCurrent().toString()); } checkClosed(); + ConfigurableRetryLogic.getInstance().storeLastQuery(sql); executeStatement(new StmtExecCmd(this, sql, EXECUTE, NO_GENERATED_KEYS)); loggerExternal.exiting(getClassNameLogging(), "execute", null != resultSet); return null != resultSet; diff --git a/src/test/java/com/microsoft/sqlserver/jdbc/AlwaysEncrypted/JDBCEncryptionDecryptionTest.java b/src/test/java/com/microsoft/sqlserver/jdbc/AlwaysEncrypted/JDBCEncryptionDecryptionTest.java index 7376baca7..6f3502933 100644 --- a/src/test/java/com/microsoft/sqlserver/jdbc/AlwaysEncrypted/JDBCEncryptionDecryptionTest.java +++ b/src/test/java/com/microsoft/sqlserver/jdbc/AlwaysEncrypted/JDBCEncryptionDecryptionTest.java @@ -313,7 +313,7 @@ public void testAkvDecryptColumnEncryptionKey(String serverName, String url, Str akv.decryptColumnEncryptionKey("http:///^[!#$&-;=?-[]_a-", "", null); fail(TestResource.getResource("R_expectedExceptionNotThrown")); } catch (SQLServerException e) { - assertTrue(e.getMessage().matches(TestUtils.formatErrorMsg("R_AKVURLInvalid")), e.getMessage()); + assertTrue(e.getMessage().matches(TestUtils.formatErrorMsg("R_URLInvalid")), e.getMessage()); } // null encryptedColumnEncryptionKey diff --git a/src/test/java/com/microsoft/sqlserver/jdbc/SQLServerConnectionTest.java b/src/test/java/com/microsoft/sqlserver/jdbc/SQLServerConnectionTest.java index 1ed9749cc..3b7fe1dc7 100644 --- a/src/test/java/com/microsoft/sqlserver/jdbc/SQLServerConnectionTest.java +++ b/src/test/java/com/microsoft/sqlserver/jdbc/SQLServerConnectionTest.java @@ -5,9 +5,9 @@ package com.microsoft.sqlserver.jdbc; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; -import static org.junit.jupiter.api.Assertions.assertThrows; import java.io.IOException; import java.io.Reader; @@ -209,6 +209,8 @@ public void testDataSource() throws SQLServerException { ds.setCalcBigDecimalPrecision(booleanPropValue); assertEquals(booleanPropValue, ds.getCalcBigDecimalPrecision(), TestResource.getResource("R_valuesAreDifferent")); + ds.setRetryExec(stringPropValue); + assertEquals(stringPropValue, ds.getRetryExec(), TestResource.getResource("R_valuesAreDifferent")); ds.setServerCertificate(stringPropValue); assertEquals(stringPropValue, ds.getServerCertificate(), TestResource.getResource("R_valuesAreDifferent")); diff --git a/src/test/java/com/microsoft/sqlserver/jdbc/configurableretry/ConfigurableRetryLogicTest.java b/src/test/java/com/microsoft/sqlserver/jdbc/configurableretry/ConfigurableRetryLogicTest.java new file mode 100644 index 000000000..169986a0a --- /dev/null +++ b/src/test/java/com/microsoft/sqlserver/jdbc/configurableretry/ConfigurableRetryLogicTest.java @@ -0,0 +1,472 @@ +/* + * Microsoft JDBC Driver for SQL Server Copyright(c) Microsoft Corporation All rights reserved. This program is made + * available under the terms of the MIT License. See the LICENSE file in the project root for more information. + */ + +package com.microsoft.sqlserver.jdbc.configurableretry; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.File; +import java.io.FileWriter; +import java.sql.CallableStatement; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.microsoft.sqlserver.jdbc.RandomUtil; +import com.microsoft.sqlserver.jdbc.SQLServerConnection; +import com.microsoft.sqlserver.jdbc.SQLServerException; +import com.microsoft.sqlserver.jdbc.TestResource; +import com.microsoft.sqlserver.jdbc.TestUtils; +import com.microsoft.sqlserver.testframework.AbstractSQLGenerator; +import com.microsoft.sqlserver.testframework.AbstractTest; + + +/** + * Test statement retry for configurable retry logic. + */ +public class ConfigurableRetryLogicTest extends AbstractTest { + /** + * The table used throughout the tests. + */ + private static final String CRLTestTable = AbstractSQLGenerator + .escapeIdentifier(RandomUtil.getIdentifier("crlTestTable")); + + /** + * Sets up tests. + * + * @throws Exception + * if an exception occurs + */ + @BeforeAll + public static void setupTests() throws Exception { + setConnection(); + } + + /** + * Test that the SQLServerConnection methods getRetryExec and setRetryExec correctly get the existing retryExec, and + * set the retryExec connection parameter respectively. + * + * @throws Exception + * if an exception occurs + */ + @Test + public void testRetryExecConnectionStringOption() throws Exception { + try (SQLServerConnection conn = (SQLServerConnection) DriverManager.getConnection(connectionString); + Statement s = conn.createStatement()) { + String test = conn.getRetryExec(); + assertTrue(test.isEmpty()); + conn.setRetryExec("{2714:3,2*2:CREATE;2715:1,3}"); + try { + PreparedStatement ps = conn.prepareStatement("create table " + CRLTestTable + " (c1 int null);"); + createTable(s); + ps.execute(); + Assertions.fail(TestResource.getResource("R_expectedFailPassed")); + } catch (SQLServerException e) { + assertTrue(e.getMessage().startsWith("There is already an object"), + TestResource.getResource("R_unexpectedExceptionContent") + ": " + e.getMessage()); + } finally { + dropTable(s); + } + } + } + + /** + * Tests that statement retry with prepared statements correctly retries given the provided retryExec rule. + * + * @throws Exception + * if unable to connect or execute against db + */ + @Test + public void testStatementRetryPreparedStatement() throws Exception { + try (Connection conn = DriverManager.getConnection( + TestUtils.addOrOverrideProperty(connectionString, "retryExec", "{2714:3,2*2:CREATE;2715:1,3}")); + Statement s = conn.createStatement(); + PreparedStatement ps = conn.prepareStatement("create table " + CRLTestTable + " (c1 int null);")) { + try { + createTable(s); + ps.execute(); + Assertions.fail(TestResource.getResource("R_expectedFailPassed")); + } catch (SQLServerException e) { + assertTrue(e.getMessage().startsWith("There is already an object"), + TestResource.getResource("R_unexpectedExceptionContent") + ": " + e.getMessage()); + } finally { + dropTable(s); + } + } + } + + /** + * Tests that statement retry with callable statements correctly retries given the provided retryExec rule. + * + * @throws Exception + * if unable to connect or execute against db + */ + @Test + public void testStatementRetryCallableStatement() throws Exception { + try (Connection conn = DriverManager.getConnection( + TestUtils.addOrOverrideProperty(connectionString, "retryExec", "{2714:3,2*2:CREATE;2715:1,3}")); + Statement s = conn.createStatement(); + CallableStatement cs = conn.prepareCall("create table " + CRLTestTable + " (c1 int null);")) { + try { + createTable(s); + cs.execute(); + fail(TestResource.getResource("R_expectedFailPassed")); + } catch (SQLServerException e) { + assertTrue(e.getMessage().startsWith("There is already an object"), + TestResource.getResource("R_unexpectedExceptionContent") + ": " + e.getMessage()); + } finally { + dropTable(s); + } + } + } + + /** + * Tests that statement retry with SQL server statements correctly retries given the provided retryExec rule. + * + * @throws Exception + * if unable to connect or execute against db + */ + public void testStatementRetry(String addedRetryParams) throws Exception { + try (Connection conn = DriverManager.getConnection(connectionString + addedRetryParams); + Statement s = conn.createStatement()) { + try { + createTable(s); + s.execute("create table " + CRLTestTable + " (c1 int null);"); + fail(TestResource.getResource("R_expectedFailPassed")); + } catch (SQLServerException e) { + assertTrue(e.getMessage().startsWith("There is already an object"), + TestResource.getResource("R_unexpectedExceptionContent") + ": " + e.getMessage()); + } finally { + dropTable(s); + } + } + } + + /** + * Tests that statement retry with SQL server statements correctly attempts to retry, but eventually cancels due + * to the retry wait interval being longer than queryTimeout. + * + * @throws Exception + * if unable to connect or execute against db + */ + public void testStatementRetryWithShortQueryTimeout(String addedRetryParams) throws Exception { + try (Connection conn = DriverManager.getConnection(connectionString + addedRetryParams); + Statement s = conn.createStatement()) { + try { + createTable(s); + s.execute("create table " + CRLTestTable + " (c1 int null);"); + fail(TestResource.getResource("R_expectedFailPassed")); + } finally { + dropTable(s); + } + } + } + + /** + * Tests that the correct number of retries are happening for all statement scenarios. Tests are expected to take + * a minimum of the sum of whatever has been defined for the waiting intervals, and maximum of the previous sum + * plus some amount of time to account for test environment slowness. + */ + @Test + public void statementTimingTests() { + long totalTime; + long timerStart = System.currentTimeMillis(); + + // A single retry immediately + try { + testStatementRetry("retryExec={2714:1;};"); + } catch (Exception e) { + Assertions.fail(TestResource.getResource("R_unexpectedException")); + } finally { + totalTime = System.currentTimeMillis() - timerStart; + assertTrue(totalTime < TimeUnit.SECONDS.toMillis(10), + "total time: " + totalTime + ", expected time: " + TimeUnit.SECONDS.toMillis(10)); + } + + timerStart = System.currentTimeMillis(); + + // A single retry waiting 5 seconds + try { + testStatementRetry("retryExec={2714:1,5;};"); + } catch (Exception e) { + Assertions.fail(TestResource.getResource("R_unexpectedException")); + } finally { + totalTime = System.currentTimeMillis() - timerStart; + assertTrue(totalTime > TimeUnit.SECONDS.toMillis(5), + "total time: " + totalTime + ", expected minimum time: " + TimeUnit.SECONDS.toMillis(5)); + assertTrue(totalTime < TimeUnit.SECONDS.toMillis(15), + "total time: " + totalTime + ", expected maximum time: " + TimeUnit.SECONDS.toMillis(15)); + } + + timerStart = System.currentTimeMillis(); + + // Two retries. The first after 2 seconds, the next after 6 + try { + testStatementRetry("retryExec={2714,2716:2,2*3:CREATE};"); + } catch (Exception e) { + Assertions.fail(TestResource.getResource("R_unexpectedException")); + } finally { + totalTime = System.currentTimeMillis() - timerStart; + assertTrue(totalTime > TimeUnit.SECONDS.toMillis(2), + "total time: " + totalTime + ", expected minimum time: " + TimeUnit.SECONDS.toMillis(8)); + assertTrue(totalTime < TimeUnit.SECONDS.toMillis(18), + "total time: " + totalTime + ", expected maximum time: " + TimeUnit.SECONDS.toMillis(18)); + } + + timerStart = System.currentTimeMillis(); + + // Two retries. The first after 3 seconds, the next after 7 + try { + testStatementRetry("retryExec={2714,2716:2,3+4:CREATE};"); + } catch (Exception e) { + Assertions.fail(TestResource.getResource("R_unexpectedException")); + } finally { + totalTime = System.currentTimeMillis() - timerStart; + assertTrue(totalTime > TimeUnit.SECONDS.toMillis(3), + "total time: " + totalTime + ", expected minimum time: " + TimeUnit.SECONDS.toMillis(10)); + assertTrue(totalTime < TimeUnit.SECONDS.toMillis(20), + "total time: " + totalTime + ", expected maximum time: " + TimeUnit.SECONDS.toMillis(20)); + } + } + + /** + * Tests that configurable retry logic correctly parses, and retries using, multiple rules provided at once. + */ + @Test + public void multipleRules() { + try { + testStatementRetry("retryExec={2716:1,2*2:CREATE;2714:1,2*2:CREATE};"); + } catch (Exception e) { + Assertions.fail(TestResource.getResource("R_unexpectedException")); + } + } + + /** + * Tests that CRL is able to read from a properties file, in the event the connection property is not used. + */ + @Test + public void readFromFile() { + File propsFile = null; + try { + propsFile = new File("mssql-jdbc.properties"); + FileWriter propFileWriter = new FileWriter(propsFile); + propFileWriter.write("retryExec={2716:1,2*2:CREATE;2714:1,2*2:CREATE};"); + propFileWriter.close(); + testStatementRetry(""); + + } catch (Exception e) { + Assertions.fail(TestResource.getResource("R_unexpectedException")); + } finally { + if (propsFile != null && !propsFile.delete()) { // If unable to delete, fail test + Assertions.fail(TestResource.getResource("R_unexpectedException")); + } + } + } + + /** + * Ensure that CRL properly re-reads rules after INTERVAL_BETWEEN_READS_IN_MS (30 secs). + */ + @Test + public void rereadAfterInterval() { + try { + testStatementRetry("retryExec={2716:1,2*2:CREATE;};"); + Thread.sleep(30000); // Sleep to ensure it has been INTERVAL_BETWEEN_READS_IN_MS between reads + testStatementRetry("retryExec={2714:1,2*2:CREATE;};"); + } catch (Exception e) { + Assertions.fail(TestResource.getResource("R_unexpectedException")); + } + } + + /** + * Tests that rules of the correct length, and containing valid values, pass. + */ + @Test + public void testCorrectlyFormattedRules() { + // Correctly formatted rules + try { + // Empty rule set + testStatementRetry("retryExec={};"); + testStatementRetry("retryExec={;};"); + + // Test length 1 + testStatementRetry("retryExec={2714:1;};"); + + // Test length 2 + testStatementRetry("retryExec={2714:1,3;};"); + + // Test length 2, with operand, but no initial-retry-time + testStatementRetry("retryExec={2714:1,3+;};"); + testStatementRetry("retryExec={2714:1,3*;};"); + + // Test length 3, but query is empty + testStatementRetry("retryExec={2714:1,3:;};"); + + // Test length 3, also multiple statement errors + testStatementRetry("retryExec={2714,2716:1,2*2:CREATE};"); + + // Same as above but using + operator + testStatementRetry("retryExec={2714,2716:1,2+2:CREATE};"); + testStatementRetry("retryExec={2714,2716:1,2+2};"); + + } catch (Exception e) { + Assertions.fail(TestResource.getResource("R_unexpectedException")); + } + + // Test length >3 + try { + testStatementRetry("retryExec={2714,2716:1,2*2:CREATE:4};"); + } catch (SQLServerException e) { + assertTrue(e.getMessage().matches(TestUtils.formatErrorMsg("R_InvalidRuleFormat"))); + } catch (Exception e) { + Assertions.fail(TestResource.getResource("R_unexpectedException")); + } + } + + /** + * Tests that too many timing parameters (>2) causes InvalidParameterFormat Exception. + */ + @Test + public void testTooManyTimings() { + try { + testStatementRetry("retryExec={2714,2716:1,2*2,1:CREATE};"); + } catch (SQLServerException e) { + assertTrue(e.getMessage().matches(TestUtils.formatErrorMsg("R_invalidParameterNumber"))); + } catch (Exception e) { + Assertions.fail(TestResource.getResource("R_unexpectedException")); + } + } + + /** + * Tests that rules with an invalid retry error correctly fail. + * + * @throws Exception + * for the invalid parameter + */ + @Test + public void testRetryError() throws Exception { + // Test incorrect format (NaN) + try { + testStatementRetry("retryExec={TEST:TEST};"); + } catch (SQLServerException e) { + assertTrue(e.getMessage().matches(TestUtils.formatErrorMsg("R_invalidParameterNumber"))); + } + + // Test empty error + try { + testStatementRetry("retryExec={:1,2*2:CREATE};"); + } catch (SQLServerException e) { + assertTrue(e.getMessage().matches(TestUtils.formatErrorMsg("R_invalidParameterNumber"))); + } + } + + /** + * Tests that rules with an invalid retry count correctly fail. + * + * @throws Exception + * for the invalid parameter + */ + @Test + public void testRetryCount() throws Exception { + // Test min + try { + testStatementRetry("retryExec={2714,2716:-1,2+2:CREATE};"); + } catch (SQLServerException e) { + assertTrue(e.getMessage().matches(TestUtils.formatErrorMsg("R_invalidParameterNumber"))); + } + + // Test max (query timeout) + try { + testStatementRetryWithShortQueryTimeout("queryTimeout=3;retryExec={2714,2716:11,2+2:CREATE};"); + } catch (SQLServerException e) { + assertTrue(e.getMessage().matches(TestUtils.formatErrorMsg("R_InvalidRetryInterval"))); + } + } + + /** + * Tests that rules with an invalid initial retry time correctly fail. + * + * @throws Exception + * for the invalid parameter + */ + @Test + public void testInitialRetryTime() throws Exception { + // Test min + try { + testStatementRetry("retryExec={2714,2716:4,-1+1:CREATE};"); + } catch (SQLServerException e) { + assertTrue(e.getMessage().matches(TestUtils.formatErrorMsg("R_invalidParameterNumber"))); + } + + // Test max + try { + testStatementRetryWithShortQueryTimeout("queryTimeout=3;retryExec={2714,2716:4,100+1:CREATE};"); + } catch (SQLServerException e) { + assertTrue(e.getMessage().matches(TestUtils.formatErrorMsg("R_InvalidRetryInterval"))); + } + } + + /** + * Tests that rules with an invalid operand correctly fail. + * + * @throws Exception + * for the invalid parameter + */ + @Test + public void testOperand() throws Exception { + try { + testStatementRetry("retryExec={2714,2716:1,2AND2:CREATE};"); + } catch (SQLServerException e) { + assertTrue(e.getMessage().matches(TestUtils.formatErrorMsg("R_invalidParameterNumber"))); + } + } + + /** + * Tests that rules with an invalid retry change correctly fail. + * + * @throws Exception + * for the invalid parameter + */ + @Test + public void testRetryChange() throws Exception { + try { + testStatementRetry("retryExec={2714,2716:1,2+2:CREATE};"); + } catch (SQLServerException e) { + assertTrue(e.getMessage().matches(TestUtils.formatErrorMsg("R_invalidParameterNumber"))); + } + } + + /** + * Creates table for use in ConfigurableRetryLogic tests. + * + * @param stmt + * the SQL statement to use to create the table + * @throws SQLException + * if unable to execute statement + */ + private static void createTable(Statement stmt) throws SQLException { + String sql = "create table " + CRLTestTable + " (c1 int null);"; + stmt.execute(sql); + } + + /** + * Drops the table used in ConfigurableRetryLogic tests. + * + * @param stmt + * the SQL statement to use to drop the table + * @throws SQLException + * if unable to execute statement + */ + private static void dropTable(Statement stmt) throws SQLException { + TestUtils.dropTableIfExists(CRLTestTable, stmt); + } +} diff --git a/src/test/java/com/microsoft/sqlserver/jdbc/connection/RequestBoundaryMethodsTest.java b/src/test/java/com/microsoft/sqlserver/jdbc/connection/RequestBoundaryMethodsTest.java index 0ae93feb3..8d3a62788 100644 --- a/src/test/java/com/microsoft/sqlserver/jdbc/connection/RequestBoundaryMethodsTest.java +++ b/src/test/java/com/microsoft/sqlserver/jdbc/connection/RequestBoundaryMethodsTest.java @@ -521,6 +521,8 @@ private List getVerifiedMethodNames() { verifiedMethodNames.add("setCalcBigDecimalPrecision"); verifiedMethodNames.add("registerBeforeReconnectListener"); verifiedMethodNames.add("removeBeforeReconnectListener"); + verifiedMethodNames.add("getRetryExec"); + verifiedMethodNames.add("setRetryExec"); verifiedMethodNames.add("getUseFlexibleCallableStatements"); verifiedMethodNames.add("setUseFlexibleCallableStatements"); return verifiedMethodNames;