diff --git a/galasa-extensions-parent/dev.galasa.auth.couchdb/src/main/java/dev/galasa/auth/couchdb/internal/CouchdbAuthStore.java b/galasa-extensions-parent/dev.galasa.auth.couchdb/src/main/java/dev/galasa/auth/couchdb/internal/CouchdbAuthStore.java index 95093c62..a88b3810 100644 --- a/galasa-extensions-parent/dev.galasa.auth.couchdb/src/main/java/dev/galasa/auth/couchdb/internal/CouchdbAuthStore.java +++ b/galasa-extensions-parent/dev.galasa.auth.couchdb/src/main/java/dev/galasa/auth/couchdb/internal/CouchdbAuthStore.java @@ -94,7 +94,7 @@ public List getTokensByLoginId(String loginId) throws AuthSt // Build up a list of all the tokens using the document IDs for (ViewRow row : tokenDocuments) { - tokens.add(getAuthTokenFromDocument(row.key)); + tokens.add(getAuthTokenFromDocument(row.id)); } logger.info("Tokens retrieved from CouchDB OK"); diff --git a/galasa-extensions-parent/dev.galasa.auth.couchdb/src/main/java/dev/galasa/auth/couchdb/internal/CouchdbAuthStoreValidator.java b/galasa-extensions-parent/dev.galasa.auth.couchdb/src/main/java/dev/galasa/auth/couchdb/internal/CouchdbAuthStoreValidator.java index 7f5bb33a..3c3e8d41 100644 --- a/galasa-extensions-parent/dev.galasa.auth.couchdb/src/main/java/dev/galasa/auth/couchdb/internal/CouchdbAuthStoreValidator.java +++ b/galasa-extensions-parent/dev.galasa.auth.couchdb/src/main/java/dev/galasa/auth/couchdb/internal/CouchdbAuthStoreValidator.java @@ -8,7 +8,6 @@ import java.net.URI; import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; import org.apache.http.HttpEntity; import org.apache.http.HttpStatus; import org.apache.http.StatusLine; @@ -22,9 +21,13 @@ import com.google.gson.JsonSyntaxException; + +import dev.galasa.extensions.common.api.LogFactory; + import dev.galasa.extensions.common.couchdb.CouchdbBaseValidator; import dev.galasa.extensions.common.couchdb.CouchdbClashingUpdateException; import dev.galasa.extensions.common.couchdb.CouchdbException; +import dev.galasa.extensions.common.couchdb.RetryableCouchdbUpdateOperationProcessor; import dev.galasa.auth.couchdb.internal.beans.*; import dev.galasa.extensions.common.api.HttpRequestFactory; import dev.galasa.framework.spi.utils.GalasaGson; @@ -34,12 +37,27 @@ public class CouchdbAuthStoreValidator extends CouchdbBaseValidator { - private final Log logger = LogFactory.getLog(getClass()); + private final Log logger ; private final GalasaGson gson = new GalasaGson(); + private final LogFactory logFactory; // A couchDB view, it gets all the access tokens of a the user based on the loginId provided. public static final String DB_TABLE_TOKENS_DESIGN = "function (doc) { if (doc.owner && doc.owner.loginId) {emit(doc.owner.loginId, doc); } }"; + public CouchdbAuthStoreValidator() { + this(new LogFactory(){ + @Override + public Log getLog(Class clazz) { + return org.apache.commons.logging.LogFactory.getLog(clazz); + } + }); + } + + public CouchdbAuthStoreValidator(LogFactory logFactory) { + this.logFactory = logFactory; + this.logger = logFactory.getLog(getClass()); + } + @Override public void checkCouchdbDatabaseIsValid( URI couchdbUri, @@ -51,7 +69,7 @@ public void checkCouchdbDatabaseIsValid( // Perform the base CouchDB checks super.checkCouchdbDatabaseIsValid(couchdbUri, httpClient, httpRequestFactory, timeService); - RetryableCouchdbUpdateOperationProcessor retryProcessor = new RetryableCouchdbUpdateOperationProcessor(timeService); + RetryableCouchdbUpdateOperationProcessor retryProcessor = new RetryableCouchdbUpdateOperationProcessor(timeService, this.logFactory); retryProcessor.retryCouchDbUpdateOperation( ()->{ tryToCheckAndUpdateCouchDBTokenView(couchdbUri, httpClient, httpRequestFactory); @@ -170,13 +188,15 @@ private void updateTokenDesignDocument(CloseableHttpClient httpClient, URI couch try (CloseableHttpResponse response = httpClient.execute(httpPut)) { StatusLine statusLine = response.getStatusLine(); int statusCode = statusLine.getStatusCode(); + + EntityUtils.consumeQuietly(response.getEntity()); + if (statusCode == HttpStatus.SC_CONFLICT) { // Someone possibly updated the document while we were thinking about it. // It was probably another instance of this exact code. throw new CouchdbClashingUpdateException(ERROR_FAILED_TO_UPDATE_COUCHDB_DESING_DOC_CONFLICT.toString()); } - EntityUtils.consumeQuietly(response.getEntity()); if (statusCode != HttpStatus.SC_CREATED) { throw new CouchdbException( diff --git a/galasa-extensions-parent/dev.galasa.auth.couchdb/src/main/java/dev/galasa/auth/couchdb/internal/RetryableCouchdbUpdateOperationProcessor.java b/galasa-extensions-parent/dev.galasa.auth.couchdb/src/main/java/dev/galasa/auth/couchdb/internal/RetryableCouchdbUpdateOperationProcessor.java deleted file mode 100644 index b1589a20..00000000 --- a/galasa-extensions-parent/dev.galasa.auth.couchdb/src/main/java/dev/galasa/auth/couchdb/internal/RetryableCouchdbUpdateOperationProcessor.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright contributors to the Galasa project - * - * SPDX-License-Identifier: EPL-2.0 - */ -package dev.galasa.auth.couchdb.internal; - -import java.util.Random; - -import dev.galasa.extensions.common.couchdb.CouchdbClashingUpdateException; -import dev.galasa.extensions.common.couchdb.CouchdbException; -import dev.galasa.framework.spi.utils.ITimeService; - -import org.apache.commons.logging.Log; - -/** - * Allows a lambda function to be used, and that function will be retried a number of times before giving up. - */ -public class RetryableCouchdbUpdateOperationProcessor { - - public int MAX_ATTEMPTS_TO_GO_BEFORE_GIVE_UP = 10; - private ITimeService timeService ; - private Log logger; - public interface RetryableCouchdbUpdateOperation { - public void tryToUpdateCouchDb() throws CouchdbException; - } - - - public RetryableCouchdbUpdateOperationProcessor(ITimeService timeService) { - this.timeService = timeService; - } - - public void retryCouchDbUpdateOperation(RetryableCouchdbUpdateOperation retryableOperation) throws CouchdbException{ - int attemptsToGoBeforeGiveUp = MAX_ATTEMPTS_TO_GO_BEFORE_GIVE_UP; - boolean isDone = false; - - while (!isDone) { - - try { - retryableOperation.tryToUpdateCouchDb(); - - isDone = true; - } catch (CouchdbClashingUpdateException updateClashedEx) { - - logger.info("Clashing update detected. Backing off for a short time to avoid another clash immediately. "); - - waitForBackoffDelay(timeService); - - attemptsToGoBeforeGiveUp -= 1; - if (attemptsToGoBeforeGiveUp == 0) { - throw new CouchdbException("Failed after " + Integer.toString(MAX_ATTEMPTS_TO_GO_BEFORE_GIVE_UP) + " attempts to update the design document in CouchDB due to conflicts.", updateClashedEx); - } else { - logger.info("Failed to update CouchDB design document, retrying..."); - } - } - } - } - - private void waitForBackoffDelay(ITimeService timeService) { - Long delayMilliSecs = 1000L + new Random().nextInt(3000); - - try { - logger.info("Waiting "+delayMilliSecs+" during a back-off delay. starting now."); - timeService.sleepMillis(delayMilliSecs); - } catch(InterruptedException ex ) { - logger.info("Interrupted from waiting during a back-off delay. Ignoring this, but cutting our wait short."); - } - } -} \ No newline at end of file diff --git a/galasa-extensions-parent/dev.galasa.auth.couchdb/src/test/java/dev/galasa/auth/couchdb/TestCouchdbAuthStore.java b/galasa-extensions-parent/dev.galasa.auth.couchdb/src/test/java/dev/galasa/auth/couchdb/TestCouchdbAuthStore.java index a3e6289b..0ec9b21d 100644 --- a/galasa-extensions-parent/dev.galasa.auth.couchdb/src/test/java/dev/galasa/auth/couchdb/TestCouchdbAuthStore.java +++ b/galasa-extensions-parent/dev.galasa.auth.couchdb/src/test/java/dev/galasa/auth/couchdb/TestCouchdbAuthStore.java @@ -105,7 +105,7 @@ public void testGetTokensReturnsTokensWithFailingRequestReturnsError() throws Ex MockLogFactory logFactory = new MockLogFactory(); List interactions = new ArrayList(); - interactions.add(new GetAllTokenDocumentsInteraction("https://my-auth-store/galasa_tokens/_all_docs", HttpStatus.SC_INTERNAL_SERVER_ERROR, null)); + interactions.add(new GetAllTokenDocumentsInteraction("https://my-auth-store/galasa_tokens/_all_docs?include_docs=true&endkey=%22_%22", HttpStatus.SC_INTERNAL_SERVER_ERROR, null)); MockCloseableHttpClient mockHttpClient = new MockCloseableHttpClient(interactions); @@ -137,7 +137,7 @@ public void testGetTokensReturnsTokensFromCouchdbOK() throws Exception { CouchdbAuthToken mockToken = new CouchdbAuthToken("token1", "dex-client", "my test token", Instant.now(), new CouchdbUser("johndoe", "dex-user-id")); List interactions = new ArrayList(); - interactions.add(new GetAllTokenDocumentsInteraction("https://my-auth-store/galasa_tokens/_all_docs", HttpStatus.SC_OK, mockAllDocsResponse)); + interactions.add(new GetAllTokenDocumentsInteraction("https://my-auth-store/galasa_tokens/_all_docs?include_docs=true&endkey=%22_%22", HttpStatus.SC_OK, mockAllDocsResponse)); interactions.add(new GetTokenDocumentInteraction("https://my-auth-store/galasa_tokens/token1", HttpStatus.SC_OK, mockToken)); MockCloseableHttpClient mockHttpClient = new MockCloseableHttpClient(interactions); @@ -164,7 +164,7 @@ public void testGetTokensReturnsTokensByLoginIdFromCouchdbOK() throws Exception MockLogFactory logFactory = new MockLogFactory(); ViewRow tokenDoc = new ViewRow(); - tokenDoc.key = "token1"; + tokenDoc.id = "token1"; List mockDocs = List.of(tokenDoc); ViewResponse mockAllDocsResponse = new ViewResponse(); @@ -173,7 +173,7 @@ public void testGetTokensReturnsTokensByLoginIdFromCouchdbOK() throws Exception CouchdbAuthToken mockToken = new CouchdbAuthToken("token1", "dex-client", "my test token", Instant.now(), new CouchdbUser("johndoe", "dex-user-id")); CouchdbAuthToken mockToken2 = new CouchdbAuthToken("token2", "dex-client", "my test token", Instant.now(), new CouchdbUser("notJohnDoe", "dex-user-id")); List interactions = new ArrayList(); - interactions.add(new GetAllTokenDocumentsInteraction("https://my-auth-store/galasa_tokens/_design/docs/_view/loginId-view?key=johndoe", HttpStatus.SC_OK, mockAllDocsResponse)); + interactions.add(new GetAllTokenDocumentsInteraction("https://my-auth-store/galasa_tokens/_design/docs/_view/loginId-view?key=%22johndoe%22", HttpStatus.SC_OK, mockAllDocsResponse)); interactions.add(new GetTokenDocumentInteraction("https://my-auth-store/galasa_tokens/token1", HttpStatus.SC_OK, mockToken)); interactions.add(new GetTokenDocumentInteraction("https://my-auth-store/galasa_tokens/token1", HttpStatus.SC_OK, mockToken2)); @@ -183,7 +183,6 @@ public void testGetTokensReturnsTokensByLoginIdFromCouchdbOK() throws Exception MockTimeService mockTimeService = new MockTimeService(Instant.now()); CouchdbAuthStore authStore = new CouchdbAuthStore(authStoreUri, httpClientFactory, new HttpRequestFactoryImpl(), logFactory, new MockCouchdbValidator(), mockTimeService); - // When... List tokens = authStore.getTokensByLoginId("johndoe"); @@ -201,7 +200,7 @@ public void testGetTokensReturnsTokensByLoginIdWithFailingRequestReturnsError() MockLogFactory logFactory = new MockLogFactory(); List interactions = new ArrayList(); - interactions.add(new GetAllTokenDocumentsInteraction("https://my-auth-store/galasa_tokens/_design/docs/_view/loginId-view?key=johndoe", HttpStatus.SC_INTERNAL_SERVER_ERROR, null)); + interactions.add(new GetAllTokenDocumentsInteraction("https://my-auth-store/galasa_tokens/_design/docs/_view/loginId-view?key=%22johndoe%22", HttpStatus.SC_INTERNAL_SERVER_ERROR, null)); MockCloseableHttpClient mockHttpClient = new MockCloseableHttpClient(interactions); diff --git a/galasa-extensions-parent/dev.galasa.extensions.common/build.gradle b/galasa-extensions-parent/dev.galasa.extensions.common/build.gradle index 64a33227..7c43f48d 100644 --- a/galasa-extensions-parent/dev.galasa.extensions.common/build.gradle +++ b/galasa-extensions-parent/dev.galasa.extensions.common/build.gradle @@ -11,6 +11,8 @@ dependencies { implementation ('org.apache.httpcomponents:httpclient-osgi:4.5.13') implementation ('org.apache.httpcomponents:httpcore-osgi:4.4.14') implementation ('com.google.code.gson:gson:2.10.1') + + testImplementation(project(':dev.galasa.extensions.mocks')) } // Note: These values are consumed by the parent build process diff --git a/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/Errors.java b/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/Errors.java index 6470c571..7b0df5f4 100644 --- a/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/Errors.java +++ b/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/Errors.java @@ -58,6 +58,7 @@ public enum Errors { ERROR_GALASA_REST_CALL_TO_GET_CPS_NAMESPACES_FAILED (7020,"GAL7020E: Could not get the CPS namespaces information from URL ''{0}''. Cause: {1}"), ERROR_GALASA_REST_CALL_TO_GET_CPS_NAMESPACES_BAD_JSON_RETURNED (7021,"GAL7021E: Could not get the CPS namespaces value from URL ''{0}''. Cause: Bad json returned from the server. {1}"), + ERROR_GALASA_COUCHDB_UPDATED_FAILED_AFTER_RETRIES (7022,"GAL7022E: Couchdb operation failed after {0} attempts, due to conflicts."), ; private String template; diff --git a/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/CouchdbStore.java b/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/CouchdbStore.java index 8bd5878d..a7d86b99 100644 --- a/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/CouchdbStore.java +++ b/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/CouchdbStore.java @@ -8,8 +8,10 @@ import static dev.galasa.extensions.common.Errors.*; import java.io.IOException; +import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URISyntaxException; +import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.nio.file.CopyOption; import java.nio.file.Files; @@ -18,6 +20,8 @@ import java.util.stream.Collectors; import java.util.stream.IntStream; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import org.apache.http.HttpEntity; import org.apache.http.HttpStatus; import org.apache.http.ParseException; @@ -40,8 +44,10 @@ import dev.galasa.framework.spi.utils.GalasaGson; /** - * This is a base class for CouchDB implementations of Galasa stores that defines functions for common interactions - * with CouchDB, including creating documents in a database and getting all documents that are stored in a database. + * This is a base class for CouchDB implementations of Galasa stores that + * defines functions for common interactions + * with CouchDB, including creating documents in a database and getting all + * documents that are stored in a database. */ public abstract class CouchdbStore { @@ -49,11 +55,14 @@ public abstract class CouchdbStore { protected final URI storeUri; + private Log logger = LogFactory.getLog(this.getClass()); + protected HttpRequestFactory httpRequestFactory; protected CloseableHttpClient httpClient; protected GalasaGson gson = new GalasaGson(); - public CouchdbStore(URI storeUri, HttpRequestFactory httpRequestFactory, HttpClientFactory httpClientFactory) throws CouchdbException { + public CouchdbStore(URI storeUri, HttpRequestFactory httpRequestFactory, HttpClientFactory httpClientFactory) + throws CouchdbException { // Strip off the 'couchdb:' prefix from the auth store URI // e.g. couchdb:https://myhost:5984 becomes https://myhost:5984 String storeUriStr = storeUri.toString(); @@ -71,10 +80,12 @@ public CouchdbStore(URI storeUri, HttpRequestFactory httpRequestFactory, HttpCli /** * Creates a new document in the given database with the given JSON content. * - * @param dbName the database to create the new document within - * @param jsonContent the JSON content to send to CouchDB in order to populate the new document + * @param dbName the database to create the new document within + * @param jsonContent the JSON content to send to CouchDB in order to populate + * the new document * @return PutPostResponse the response from the CouchDB service - * @throws CouchdbException if there is a problem accessing the CouchDB server or creating the document + * @throws CouchdbException if there is a problem accessing the CouchDB server + * or creating the document */ protected PutPostResponse createDocument(String dbName, String jsonContent) throws CouchdbException { // Create a new document in the tokens database with the new token to store @@ -95,15 +106,20 @@ protected PutPostResponse createDocument(String dbName, String jsonContent) thro } /** - * Sends a GET request to CouchDB's /{db}/_all_docs endpoint and returns the "rows" list in the response, + * Sends a GET request to CouchDB's /{db}/_all_docs endpoint and returns the + * "rows" list in the response, * which corresponds to the list of documents within the given database. * * @param dbName the name of the database to retrieve the documents of * @return a list of rows corresponding to documents within the database - * @throws CouchdbException if there was a problem accessing the CouchDB store or its response + * @throws CouchdbException if there was a problem accessing the CouchDB store + * or its response */ protected List getAllDocsFromDatabase(String dbName) throws CouchdbException { - HttpGet getTokensDocs = httpRequestFactory.getHttpGetRequest(storeUri + "/" + dbName + "/_all_docs"); + + //The end key is "_" because, design docs start with "_design", + // this will exclude any design documents from being fetched from couchdb. + HttpGet getTokensDocs = httpRequestFactory.getHttpGetRequest(storeUri + "/" + dbName + "/_all_docs?include_docs=true&endkey=%22_%22"); String responseEntity = sendHttpRequest(getTokensDocs, HttpStatus.SC_OK); ViewResponse allDocs = gson.fromJson(responseEntity, ViewResponse.class); @@ -113,21 +129,29 @@ protected List getAllDocsFromDatabase(String dbName) throws CouchdbExce String errorMessage = ERROR_FAILED_TO_GET_DOCUMENTS_FROM_DATABASE.getMessage(dbName); throw new CouchdbException(errorMessage); } - + return viewRows; } /** - * Sends a GET request to CouchDB's /{db}/_design/docs/_view/loginId-view?key={loginId} endpoint and returns the "rows" list in the response, + * Sends a GET request to CouchDB's + * /{db}/_design/docs/_view/loginId-view?key={loginId} endpoint and returns the + * "rows" list in the response, * which corresponds to the list of documents within the given database. * - * @param dbName the name of the database to retrieve the documents of + * @param dbName the name of the database to retrieve the documents of * @param loginId the loginId of the user to retrieve the doucemnts of * @return a list of rows corresponding to documents within the database - * @throws CouchdbException if there was a problem accessing the CouchDB store or its response + * @throws CouchdbException if there was a problem accessing the + * CouchDB store or its response + * @throws UnsupportedEncodingException A failure occurred. */ protected List getAllDocsByLoginId(String dbName, String loginId) throws CouchdbException { - HttpGet getTokensDocs = httpRequestFactory.getHttpGetRequest(storeUri + "/" + dbName + "/_design/docs/_view/loginId-view?key=" + loginId); + + String encodedLoginId = URLEncoder.encode("\"" + loginId + "\"", StandardCharsets.UTF_8); + String url = storeUri + "/" + dbName + "/_design/docs/_view/loginId-view?key=" + encodedLoginId; + + HttpGet getTokensDocs = httpRequestFactory.getHttpGetRequest(url); getTokensDocs.addHeader("Content-Type", "application/json"); String responseEntity = sendHttpRequest(getTokensDocs, HttpStatus.SC_OK); @@ -144,28 +168,32 @@ protected List getAllDocsByLoginId(String dbName, String loginId) throw } /** - * Gets an object from a given database's document using its document ID by sending a + * Gets an object from a given database's document using its document ID by + * sending a * GET /{db}/{docid} request to the CouchDB server. * - * @param The object type to be returned - * @param dbName the name of the database to retrieve the document from - * @param documentId the CouchDB ID for the document to retrieve - * @param classOfObject the class of the JSON object to retrieve from the CouchDB Document + * @param The object type to be returned + * @param dbName the name of the database to retrieve the document from + * @param documentId the CouchDB ID for the document to retrieve + * @param classOfObject the class of the JSON object to retrieve from the + * CouchDB Document * @return an object of the class provided in classOfObject - * @throws CouchdbException if there was a problem accessing the CouchDB store or its response + * @throws CouchdbException if there was a problem accessing the CouchDB store + * or its response */ - protected T getDocumentFromDatabase(String dbName, String documentId, Class classOfObject) throws CouchdbException { + protected T getDocumentFromDatabase(String dbName, String documentId, Class classOfObject) + throws CouchdbException { HttpGet getDocumentRequest = httpRequestFactory.getHttpGetRequest(storeUri + "/" + dbName + "/" + documentId); return gson.fromJson(sendHttpRequest(getDocumentRequest, HttpStatus.SC_OK), classOfObject); } - - protected void retrieveArtifactFromDatabase(String URI, Path cachePath, CopyOption copyOption) throws CouchdbException{ + protected void retrieveArtifactFromDatabase(String URI, Path cachePath, CopyOption copyOption) + throws CouchdbException { HttpGet httpGet = httpRequestFactory.getHttpGetRequest(URI); try (CloseableHttpResponse response = httpClient.execute(httpGet)) { StatusLine statusLine = response.getStatusLine(); if (statusLine.getStatusCode() != HttpStatus.SC_OK) { - String errorMessage = ERROR_URI_IS_INVALID .getMessage(URI); + String errorMessage = ERROR_URI_IS_INVALID.getMessage(URI); throw new CouchdbException(errorMessage); } HttpEntity entity = response.getEntity(); @@ -181,9 +209,10 @@ protected void retrieveArtifactFromDatabase(String URI, Path cachePath, CopyOpti * Deletes a document from a given database using its document ID by sending a * DELETE /{db}/{docid} request to the CouchDB server. * - * @param dbName the name of the database to delete the document from + * @param dbName the name of the database to delete the document from * @param documentId the CouchDB ID for the document to delete - * @throws CouchdbException if there was a problem accessing the CouchDB store or its response + * @throws CouchdbException if there was a problem accessing the CouchDB store + * or its response */ protected void deleteDocumentFromDatabase(String dbName, String documentId) throws CouchdbException { IdRev documentIdRev = getDocumentFromDatabase(dbName, documentId, IdRev.class); @@ -199,14 +228,18 @@ protected void deleteDocumentFromDatabase(String dbName, String documentId) thro } /** - * Sends a given HTTP request to the CouchDB server and returns the response body as a string. + * Sends a given HTTP request to the CouchDB server and returns the response + * body as a string. * - * @param httpRequest the HTTP request to send to the CouchDB server - * @param expectedHttpStatusCodes the expected Status code to get from the CouchDb server upon the request being actioned + * @param httpRequest the HTTP request to send to the CouchDB server + * @param expectedHttpStatusCodes the expected Status code to get from the + * CouchDb server upon the request being actioned * @return a string representation of the response. - * @throws CouchdbException if there was a problem accessing the CouchDB store or its response + * @throws CouchdbException if there was a problem accessing the CouchDB store + * or its response */ - protected String sendHttpRequest(HttpUriRequest httpRequest, int... expectedHttpStatusCodes) throws CouchdbException { + protected String sendHttpRequest(HttpUriRequest httpRequest, int... expectedHttpStatusCodes) + throws CouchdbException { String responseEntity = ""; try (CloseableHttpResponse response = httpClient.execute(httpRequest)) { StatusLine statusLine = response.getStatusLine(); @@ -214,10 +247,11 @@ protected String sendHttpRequest(HttpUriRequest httpRequest, int... expectedHttp if (!isStatusCodeExpected(actualStatusCode, expectedHttpStatusCodes)) { String expectedStatusCodesStr = IntStream.of(expectedHttpStatusCodes) - .mapToObj(Integer::toString) - .collect(Collectors.joining(", ")); + .mapToObj(Integer::toString) + .collect(Collectors.joining(", ")); - String errorMessage = ERROR_UNEXPECTED_COUCHDB_HTTP_RESPONSE.getMessage(httpRequest.getURI().toString(), expectedStatusCodesStr, actualStatusCode); + String errorMessage = ERROR_UNEXPECTED_COUCHDB_HTTP_RESPONSE.getMessage(httpRequest.getURI().toString(), + expectedStatusCodesStr, actualStatusCode); throw new CouchdbException(errorMessage); } @@ -225,18 +259,22 @@ protected String sendHttpRequest(HttpUriRequest httpRequest, int... expectedHttp responseEntity = EntityUtils.toString(entity); } catch (ParseException | IOException e) { - String errorMessage = ERROR_FAILURE_OCCURRED_WHEN_CONTACTING_COUCHDB.getMessage(httpRequest.getURI().toString(), e.getMessage()); + String errorMessage = ERROR_FAILURE_OCCURRED_WHEN_CONTACTING_COUCHDB + .getMessage(httpRequest.getURI().toString(), e.getMessage()); throw new CouchdbException(errorMessage, e); } return responseEntity; } /** - * Checks if a given status code is an expected status code using a given array of expected status codes. + * Checks if a given status code is an expected status code using a given array + * of expected status codes. * - * @param actualStatusCode the status code to check - * @param expectedStatusCodes an array of expected status codes returned from CouchDB - * @return true if the actual status code is an expected status code, false otherwise + * @param actualStatusCode the status code to check + * @param expectedStatusCodes an array of expected status codes returned from + * CouchDB + * @return true if the actual status code is an expected status code, false + * otherwise */ private boolean isStatusCodeExpected(int actualStatusCode, int... expectedStatusCodes) { boolean isExpectedStatusCode = false; diff --git a/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/RetryableCouchdbUpdateOperationProcessor.java b/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/RetryableCouchdbUpdateOperationProcessor.java new file mode 100644 index 00000000..2d4b77e4 --- /dev/null +++ b/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/RetryableCouchdbUpdateOperationProcessor.java @@ -0,0 +1,111 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ +package dev.galasa.extensions.common.couchdb; + +import java.util.Random; + +import dev.galasa.extensions.common.Errors; +import dev.galasa.extensions.common.api.LogFactory; +import dev.galasa.framework.spi.utils.ITimeService; + +import org.apache.commons.logging.Log; + +/** + * Allows a lambda function to be used, and that function will be retried a number of times before giving up. + */ +public class RetryableCouchdbUpdateOperationProcessor { + + public static final int DEFAULT_MAX_ATTEMPTS_TO_GO_BEFORE_GIVE_UP = 10; + + private ITimeService timeService ; + private Log logger; + + /** + * Lambda supplying the code which should be repeated during successive attempts + */ + public interface RetryableCouchdbUpdateOperation { + /** + * @throws CouchdbException Something went wrong and no retries should be attempted, failing by passing this error upwards to the caller. + * @throws CouchdbClashingUpdateException Couchdb can't do the update right now, the routine will be re-tried. + */ + public void tryToUpdateCouchDb() throws CouchdbException, CouchdbClashingUpdateException; + } + + /** + * Calculates how much time delay we need to leave between attempts to update. + */ + public interface BackoffTimeCalculator { + /** + * @return The number of milliseconds to wait between successive re-tries of the couchdb update operation. + */ + public default long getBackoffDelayMillis() { + return 1000L + new Random().nextInt(3000); + } + } + + public RetryableCouchdbUpdateOperationProcessor(ITimeService timeService, LogFactory logFactory ) { + this.timeService = timeService; + this.logger = logFactory.getLog(this.getClass()); + } + + /** + * Pass an operation you want retried if it fails with a CouchdbClashingUpdateException, using defaults. + * + * It retries for a default number of times before giving up, with a default random backoff time between retry attempts. + * + * @param retryableOperation The operation we want to retry. + * @throws CouchdbException A failure occurred. + */ + public void retryCouchDbUpdateOperation(RetryableCouchdbUpdateOperation retryableOperation) throws CouchdbException { + retryCouchDbUpdateOperation(retryableOperation, DEFAULT_MAX_ATTEMPTS_TO_GO_BEFORE_GIVE_UP, new BackoffTimeCalculator() {} ); + } + + /** + * Pass an operation you want retried if it fails with a CouchdbClashingUpdateException. + * + * @param retryableOperation The operation we want to retry. + * @param attemptsToGoBeforeGiveUp The number of times the retryable operation is attempted before eventually giving up with a failure. + * @param backofftimeCalculator The lambda operation we consult to find out backoff times between retry attempts + * @throws CouchdbException A failure occurred. + */ + public void retryCouchDbUpdateOperation(RetryableCouchdbUpdateOperation retryableOperation, int attemptsToGoBeforeGiveUp, BackoffTimeCalculator backofftimeCalculator) throws CouchdbException{ + boolean isDone = false; + int retriesRemaining = attemptsToGoBeforeGiveUp; + + while (!isDone) { + + try { + retryableOperation.tryToUpdateCouchDb(); + + isDone = true; + } catch (CouchdbClashingUpdateException updateClashedEx) { + + logger.info("Clashing update detected. Backing off for a short time to avoid another clash immediately. "); + + waitForBackoffDelay(timeService, backofftimeCalculator); + + retriesRemaining -= 1; + if (retriesRemaining == 0) { + String msg = Errors.ERROR_GALASA_COUCHDB_UPDATED_FAILED_AFTER_RETRIES.getMessage(Integer.toString(attemptsToGoBeforeGiveUp)); + logger.info(msg); + throw new CouchdbException(msg, updateClashedEx); + } else { + logger.info("Failed to perform the couchdb operation, retrying..."); + } + } + } + } + + void waitForBackoffDelay(ITimeService timeService2, BackoffTimeCalculator backofftimeCalculator) { + long delayMilliSecs = backofftimeCalculator.getBackoffDelayMillis(); + try { + logger.info("Waiting "+delayMilliSecs+" during a back-off delay. starting now."); + timeService2.sleepMillis(delayMilliSecs); + } catch(InterruptedException ex ) { + logger.info("Interrupted from waiting during a back-off delay. Ignoring this, but cutting our wait short."); + } + } +} \ No newline at end of file diff --git a/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/pojos/ViewRow.java b/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/pojos/ViewRow.java index 61b39dbd..6decec20 100644 --- a/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/pojos/ViewRow.java +++ b/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/pojos/ViewRow.java @@ -7,6 +7,7 @@ public class ViewRow { + public String id; public String key; public Object value; diff --git a/galasa-extensions-parent/dev.galasa.extensions.common/src/test/java/dev/galasa/extensions/common/couchdb/CouchDbVersionTest.java b/galasa-extensions-parent/dev.galasa.extensions.common/src/test/java/dev/galasa/extensions/common/couchdb/CouchDbVersionTest.java index 287e9384..c43099ea 100644 --- a/galasa-extensions-parent/dev.galasa.extensions.common/src/test/java/dev/galasa/extensions/common/couchdb/CouchDbVersionTest.java +++ b/galasa-extensions-parent/dev.galasa.extensions.common/src/test/java/dev/galasa/extensions/common/couchdb/CouchDbVersionTest.java @@ -36,7 +36,6 @@ public void testInvalidVersionStringThrowsParsingError() throws Exception { CouchdbException ex = catchThrowableOfType( ()->{ new CouchDbVersion(invalidVersion); }, CouchdbException.class ); assertThat(ex).hasMessageContaining("GAL6010E: Invalid CouchDB server version format detected. The CouchDB version '" + invalidVersion + "'"); - // TODO: Assert that more of the message is in here. } @Test @@ -47,7 +46,6 @@ public void testInvalidLeadingDotVersionStringThrowsParsingError() throws Except CouchdbException ex = catchThrowableOfType( ()->{ new CouchDbVersion(invalidVersion); }, CouchdbException.class ); assertThat(ex).hasMessageContaining("GAL6010E: Invalid CouchDB server version format detected. The CouchDB version '" + invalidVersion + "'"); - // TODO: Assert that more of the message is in here. } diff --git a/galasa-extensions-parent/dev.galasa.extensions.common/src/test/java/dev/galasa/extensions/common/couchdb/RetryableCouchdbUpdateOperationProcessorTest.java b/galasa-extensions-parent/dev.galasa.extensions.common/src/test/java/dev/galasa/extensions/common/couchdb/RetryableCouchdbUpdateOperationProcessorTest.java new file mode 100644 index 00000000..44f2a7c6 --- /dev/null +++ b/galasa-extensions-parent/dev.galasa.extensions.common/src/test/java/dev/galasa/extensions/common/couchdb/RetryableCouchdbUpdateOperationProcessorTest.java @@ -0,0 +1,143 @@ +package dev.galasa.extensions.common.couchdb; + +import java.time.Instant; + +import org.junit.Test; + +import dev.galasa.extensions.common.couchdb.RetryableCouchdbUpdateOperationProcessor.BackoffTimeCalculator; +import dev.galasa.extensions.common.couchdb.RetryableCouchdbUpdateOperationProcessor.RetryableCouchdbUpdateOperation; +import dev.galasa.extensions.mocks.MockLogFactory; +import dev.galasa.extensions.mocks.MockTimeService; + +import static org.assertj.core.api.Assertions.*; + +public class RetryableCouchdbUpdateOperationProcessorTest { + + + @Test + public void testSuccessfulUpdateDoesNotThrowException() throws Exception { + MockTimeService mockTimeService = new MockTimeService(Instant.EPOCH); + MockLogFactory mockLogFactory = new MockLogFactory(); + + int attemptsBeforeGivingUp = 10; + BackoffTimeCalculator backoffTimeCalculator = new BackoffTimeCalculator() {}; + + RetryableCouchdbUpdateOperationProcessor processor = new RetryableCouchdbUpdateOperationProcessor(mockTimeService, mockLogFactory); + + RetryableCouchdbUpdateOperation operationThatPasses = new RetryableCouchdbUpdateOperation() { + @Override + public void tryToUpdateCouchDb() throws CouchdbException, CouchdbClashingUpdateException { + // Simulate successful update + } + }; + + // When... + processor.retryCouchDbUpdateOperation(operationThatPasses, attemptsBeforeGivingUp, backoffTimeCalculator); + + // Then... + // No errors should have been thrown + assertThat(mockTimeService.now()).as("time passed, procesor waited when it should not have done so.").isEqualTo(Instant.EPOCH); + assertThat(mockLogFactory.toString()).as("retry processor logged something, when nothing was expected if the retry operation passes first time.").isBlank(); + } + + @Test + public void testRetriesUntilItGivesUp() throws Exception { + MockTimeService mockTimeService = new MockTimeService(Instant.EPOCH); + MockLogFactory mockLogFactory = new MockLogFactory(); + int attemptsBeforeGivingUp = 10; + + // A backoff of 1ms each time, so there is no random element in a unit test, and we can compare the time delayed later. + BackoffTimeCalculator backoffTimeCalculator = new BackoffTimeCalculator() { + public long getBackoffDelayMillis() { + return 1; + } + }; + + RetryableCouchdbUpdateOperationProcessor processor = new RetryableCouchdbUpdateOperationProcessor(mockTimeService, mockLogFactory); + + RetryableCouchdbUpdateOperation operationThatFails = new RetryableCouchdbUpdateOperation() { + @Override + public void tryToUpdateCouchDb() throws CouchdbException, CouchdbClashingUpdateException { + throw new CouchdbClashingUpdateException("simulating constant failures"); + } + }; + + // When... + CouchdbException thrown = catchThrowableOfType(() -> { + processor.retryCouchDbUpdateOperation(operationThatFails, attemptsBeforeGivingUp, backoffTimeCalculator); + }, CouchdbException.class); + + // Then + assertThat(thrown).isNotNull(); + assertThat(thrown.getMessage()).contains("Couchdb operation failed after 10 attempts"); + + // We expect 10 backoff attempts, so the time would have advanced by 10 times the backoff time, which is a constant in this test of 1ms + assertThat(mockTimeService.now()).as("time passed, procesor waited when it should not have done so.").isEqualTo(Instant.EPOCH.plusMillis(attemptsBeforeGivingUp)); + + assertThat(mockLogFactory.toString()).as("retry processor didn't log what we expected. ").contains("Couchdb operation failed after 10 attempts","due to conflicts."); + } + + @Test + public void testOperationThatPassesAfterFailureDoesNotThrowError() throws Exception { + MockTimeService mockTimeService = new MockTimeService(Instant.EPOCH); + MockLogFactory mockLogFactory = new MockLogFactory(); + RetryableCouchdbUpdateOperationProcessor processor = new RetryableCouchdbUpdateOperationProcessor(mockTimeService, mockLogFactory); + int attemptsBeforeGivingUp = 10; + BackoffTimeCalculator backoffTimeCalculator = new BackoffTimeCalculator() { + public long getBackoffDelayMillis() { + return 1; + } + }; + + RetryableCouchdbUpdateOperation operationThatPassesAfterFailure = new RetryableCouchdbUpdateOperation() { + private boolean hasTriedUpdating = false; + + @Override + public void tryToUpdateCouchDb() throws CouchdbException, CouchdbClashingUpdateException { + if (!hasTriedUpdating) { + hasTriedUpdating = true; + throw new CouchdbClashingUpdateException("simulating constant failures"); + } + } + }; + + // When... + processor.retryCouchDbUpdateOperation(operationThatPassesAfterFailure, attemptsBeforeGivingUp, backoffTimeCalculator); + + // Then + // No errors should have been thrown + } + + @Test + public void testDefaultBackoffTimeCalculatorGivesNumbersWithinExpectedRange() throws Exception { + BackoffTimeCalculator backoffTimeCalculator = new BackoffTimeCalculator(){}; + for(int i=0; i<100; i++) { + long millis = backoffTimeCalculator.getBackoffDelayMillis(); + assertThat(millis).isGreaterThan(1000L); + assertThat(millis).isLessThanOrEqualTo(4000L); + } + } + + @Test + public void testWaitForBackOffDelayLogsInterruptedException() throws Exception { + MockTimeService mockTimeService = new MockTimeService(Instant.EPOCH){ + @Override + public void sleepMillis(long millis) throws InterruptedException { + // Simulate InterruptedException + throw new InterruptedException(); + } + }; + MockLogFactory mockLogFactory = new MockLogFactory(); + + // A backoff of 1ms each time, so there is no random element in a unit test, and we can compare the time delayed later. + BackoffTimeCalculator backoffTimeCalculator = new BackoffTimeCalculator() {}; + + RetryableCouchdbUpdateOperationProcessor processor = new RetryableCouchdbUpdateOperationProcessor(mockTimeService, mockLogFactory); + + // When... + processor.waitForBackoffDelay(mockTimeService, backoffTimeCalculator); + + // Then + assertThat(mockLogFactory.toString()).as("retry processor didn't log what we expected. ").contains("Interrupted from waiting during a back-off delay. Ignoring this, but cutting our wait short."); + } +} diff --git a/galasa-extensions-parent/dev.galasa.extensions.mocks/src/main/java/dev/galasa/extensions/mocks/MockTimeService.java b/galasa-extensions-parent/dev.galasa.extensions.mocks/src/main/java/dev/galasa/extensions/mocks/MockTimeService.java index 68eb175e..877cf151 100644 --- a/galasa-extensions-parent/dev.galasa.extensions.mocks/src/main/java/dev/galasa/extensions/mocks/MockTimeService.java +++ b/galasa-extensions-parent/dev.galasa.extensions.mocks/src/main/java/dev/galasa/extensions/mocks/MockTimeService.java @@ -32,9 +32,4 @@ public void setCurrentTime(Instant currentTime) { this.currentTime = currentTime; } - @Override - public void sleepMillis(long millisToSleep) throws InterruptedException { - // Pretend we are sleeping, so the current time advances. - setCurrentTime(currentTime.plusMillis(millisToSleep)); - } } diff --git a/galasa-extensions-parent/dev.galasa.ras.couchdb/src/main/java/dev/galasa/ras/couchdb/internal/CouchdbValidatorImpl.java b/galasa-extensions-parent/dev.galasa.ras.couchdb/src/main/java/dev/galasa/ras/couchdb/internal/CouchdbValidatorImpl.java index 4d18d26b..7084dabc 100644 --- a/galasa-extensions-parent/dev.galasa.ras.couchdb/src/main/java/dev/galasa/ras/couchdb/internal/CouchdbValidatorImpl.java +++ b/galasa-extensions-parent/dev.galasa.ras.couchdb/src/main/java/dev/galasa/ras/couchdb/internal/CouchdbValidatorImpl.java @@ -45,6 +45,7 @@ public class CouchdbValidatorImpl implements CouchdbValidator { private static final CouchDbVersion minCouchDbVersion = new CouchDbVersion(3,3,3); + @Override public void checkCouchdbDatabaseIsValid( URI rasUri, CloseableHttpClient httpClient ,