From 1893653abdfa061af382bdea0d72d8ca5c520c9a Mon Sep 17 00:00:00 2001 From: Rob Rudin Date: Mon, 20 Oct 2025 17:43:26 -0400 Subject: [PATCH] MLE-24779 Added mlWaitTillReady Includes some Copilot-generated tests of marginal usefulness. Have manually tested this, working well, will try out in the Node Client repo next. --- .gitignore | 1 + gradle.properties | 4 +- .../marklogic/gradle/MarkLogicPlugin.groovy | 2 + .../task/admin/WaitTillReadyTask.groovy | 36 ++++++ .../client/ext/util/ConnectionChecker.java | 98 ++++++++++++++++ .../ext/util/ConnectionCheckerTest.java | 110 ++++++++++++++++++ 6 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 ml-gradle/src/main/groovy/com/marklogic/gradle/task/admin/WaitTillReadyTask.groovy create mode 100644 ml-javaclient-util/src/main/java/com/marklogic/client/ext/util/ConnectionChecker.java create mode 100644 ml-javaclient-util/src/test/java/com/marklogic/client/ext/util/ConnectionCheckerTest.java diff --git a/.gitignore b/.gitignore index f7313d91..96551657 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /bin +bin .classpath build .gradle diff --git a/gradle.properties b/gradle.properties index 7396faa5..21605b72 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,10 +1,10 @@ -version=6.1-SNAPSHOT +version=6.2-SNAPSHOT # See https://github.com/FasterXML/jackson for more information the Jackson libraries. # This should match the version used by the MarkLogic Java Client. jacksonVersion=2.20.0 -springVersion=6.2.11 +springVersion=6.2.12 # Define these on the command line to publish to OSSRH # See https://central.sonatype.org/publish/publish-gradle/#credentials for more information diff --git a/ml-gradle/src/main/groovy/com/marklogic/gradle/MarkLogicPlugin.groovy b/ml-gradle/src/main/groovy/com/marklogic/gradle/MarkLogicPlugin.groovy index fba75aff..4daddacb 100644 --- a/ml-gradle/src/main/groovy/com/marklogic/gradle/MarkLogicPlugin.groovy +++ b/ml-gradle/src/main/groovy/com/marklogic/gradle/MarkLogicPlugin.groovy @@ -14,6 +14,7 @@ import com.marklogic.appdeployer.util.SimplePropertiesSource import com.marklogic.gradle.task.* import com.marklogic.gradle.task.admin.InitTask import com.marklogic.gradle.task.admin.InstallAdminTask +import com.marklogic.gradle.task.admin.WaitTillReadyTask import com.marklogic.gradle.task.alert.DeleteAllAlertConfigsTask import com.marklogic.gradle.task.alert.DeployAlertingTask import com.marklogic.gradle.task.client.* @@ -125,6 +126,7 @@ class MarkLogicPlugin implements Plugin { project.task("mlInit", type: InitTask, group: adminGroup, description: "Perform a one-time initialization of a MarkLogic server; uses the properties 'mlLicenseKey' and 'mlLicensee'") project.task("mlInstallAdmin", type: InstallAdminTask, group: adminGroup, description: "Perform a one-time installation of an admin user; uses the properties 'mlUsername' and 'mlPassword'; " + "the realm, which defaults to 'public', can optionally be specified on the command line via '-Prealm='") + project.task("mlWaitTillReady", type: WaitTillReadyTask, group: adminGroup, description: "Wait until MarkLogic is ready and accessible; useful in CI/CD pipelines after installing or restarting MarkLogic.") String alertGroup = "ml-gradle Alert" project.task("mlDeleteAllAlertConfigs", type: DeleteAllAlertConfigsTask, group: alertGroup, description: "Delete all alert configs, which also deletes all of the actions rules associated with them") diff --git a/ml-gradle/src/main/groovy/com/marklogic/gradle/task/admin/WaitTillReadyTask.groovy b/ml-gradle/src/main/groovy/com/marklogic/gradle/task/admin/WaitTillReadyTask.groovy new file mode 100644 index 00000000..26ebf8b8 --- /dev/null +++ b/ml-gradle/src/main/groovy/com/marklogic/gradle/task/admin/WaitTillReadyTask.groovy @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2015-2025 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. + */ +package com.marklogic.gradle.task.admin + + +import com.marklogic.client.ext.util.ConnectionChecker +import com.marklogic.gradle.task.MarkLogicTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.TaskAction + +/** + * Waits until MarkLogic is ready by using ConnectionChecker to verify database connectivity via the App-Services port (8000). + * This is useful in CI/CD pipelines where MarkLogic may have just been installed or restarted. + * Delegates to ConnectionChecker for the actual connection testing logic. + */ +class WaitTillReadyTask extends MarkLogicTask { + + @Input + @Optional + Long waitInterval = 3000L + + @Input + @Optional + Integer maxAttempts = 20 + + @TaskAction + void waitTillReady() { + new ConnectionChecker( + { -> getAppConfig().newAppServicesDatabaseClient(null) } as java.util.function.Supplier, + waitInterval, + maxAttempts + ).waitUntilReady() + } +} diff --git a/ml-javaclient-util/src/main/java/com/marklogic/client/ext/util/ConnectionChecker.java b/ml-javaclient-util/src/main/java/com/marklogic/client/ext/util/ConnectionChecker.java new file mode 100644 index 00000000..9d1064d3 --- /dev/null +++ b/ml-javaclient-util/src/main/java/com/marklogic/client/ext/util/ConnectionChecker.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2015-2025 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. + */ +package com.marklogic.client.ext.util; + +import com.marklogic.client.DatabaseClient; +import com.marklogic.client.ext.helper.LoggingObject; + +import java.util.function.Supplier; + +/** + * Immutable utility class for checking if a MarkLogic DatabaseClient connection is ready and functional. + * This is particularly useful in CI/CD pipelines where MarkLogic may have just been installed + * or restarted, and you need to wait until it's ready to handle database requests. + */ + +public class ConnectionChecker extends LoggingObject { + + private final Supplier clientSupplier; + private final long waitInterval; // milliseconds + private final int maxAttempts; + + /** + * Create a new ConnectionChecker with the given DatabaseClient supplier and default settings. + * Default wait interval is 3000ms, default max attempts is 20. + * + * @param clientSupplier a Supplier that creates a new DatabaseClient for each attempt + */ + public ConnectionChecker(Supplier clientSupplier) { + this(clientSupplier, 3000L, 20); + } + + /** + * Create a new ConnectionChecker with the given DatabaseClient supplier and custom settings. + * + * @param clientSupplier a Supplier that creates a new DatabaseClient for each attempt + * @param waitIntervalMs wait interval in milliseconds (must be positive) + * @param maxAttempts maximum number of attempts (must be positive) + */ + public ConnectionChecker(Supplier clientSupplier, long waitIntervalMs, int maxAttempts) { + if (clientSupplier == null) { + throw new IllegalArgumentException("DatabaseClient supplier cannot be null"); + } + if (waitIntervalMs <= 0) { + throw new IllegalArgumentException("Wait interval must be positive"); + } + if (maxAttempts <= 0) { + throw new IllegalArgumentException("Max attempts must be positive"); + } + this.clientSupplier = clientSupplier; + this.waitInterval = waitIntervalMs; + this.maxAttempts = maxAttempts; + } + + /** + * Wait until the DatabaseClient connection is ready, retrying up to maxAttempts times. + * + * @return true when the connection becomes ready + * @throws RuntimeException if the connection is not ready after maxAttempts + */ + public boolean waitUntilReady() { + logger.info("Waiting for MarkLogic to be ready (checking every {}ms, max attempts: {})", waitInterval, maxAttempts); + + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + String errorMessage; + try (DatabaseClient client = clientSupplier.get()) { + DatabaseClient.ConnectionResult result = client.checkConnection(); + if (result.isConnected()) { + logger.info("MarkLogic is ready (connected on attempt {})", attempt); + return true; + } + errorMessage = "Attempt %d failed with status code %d: %s".formatted(attempt, result.getStatusCode(), result.getErrorMessage()); + } catch (Exception e) { + errorMessage = "Attempt %d failed: %s".formatted(attempt, e.getMessage()); + } + + if (attempt < maxAttempts) { + logger.info("{}; waiting {}ms before retry...", errorMessage, waitInterval); + try { + Thread.sleep(waitInterval); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Connection check was interrupted", e); + } + } + } + + throw new RuntimeException(format("Failed to connect to MarkLogic after %d attempts (waited %dms between attempts)", maxAttempts, waitInterval)); + } + + public long getWaitInterval() { + return waitInterval; + } + + public int getMaxAttempts() { + return maxAttempts; + } +} diff --git a/ml-javaclient-util/src/test/java/com/marklogic/client/ext/util/ConnectionCheckerTest.java b/ml-javaclient-util/src/test/java/com/marklogic/client/ext/util/ConnectionCheckerTest.java new file mode 100644 index 00000000..38283cd5 --- /dev/null +++ b/ml-javaclient-util/src/test/java/com/marklogic/client/ext/util/ConnectionCheckerTest.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2015-2025 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. + */ +package com.marklogic.client.ext.util; + +import com.marklogic.client.DatabaseClient; +import com.marklogic.client.FailedRequestException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ConnectionCheckerTest { + + @Mock + private DatabaseClient mockClient; + + @Mock + private DatabaseClient.ConnectionResult mockConnectionResult; + + private ConnectionChecker connectionChecker; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + connectionChecker = new ConnectionChecker(() -> mockClient); + } + + @Test + void testConstructorWithNullClient() { + assertThrows(IllegalArgumentException.class, () -> new ConnectionChecker((java.util.function.Supplier) null)); + assertThrows(IllegalArgumentException.class, () -> new ConnectionChecker((java.util.function.Supplier) null, 2000L, 15)); + } + + @Test + void testConstructorWithCustomSettings() { + ConnectionChecker checker = new ConnectionChecker(() -> mockClient, 5000L, 30); + assertEquals(5000L, checker.getWaitInterval()); + assertEquals(30, checker.getMaxAttempts()); + } + + @Test + void testConstructorWithInvalidSettings() { + assertThrows(IllegalArgumentException.class, () -> new ConnectionChecker(() -> mockClient, 0L, 15)); + assertThrows(IllegalArgumentException.class, () -> new ConnectionChecker(() -> mockClient, -1L, 15)); + assertThrows(IllegalArgumentException.class, () -> new ConnectionChecker(() -> mockClient, 2000L, 0)); + assertThrows(IllegalArgumentException.class, () -> new ConnectionChecker(() -> mockClient, 2000L, -1)); + } + + @Test + void testWaitUntilReadySuccessFirstTry() { + when(mockClient.checkConnection()).thenReturn(mockConnectionResult); + when(mockConnectionResult.isConnected()).thenReturn(true); + + assertTrue(connectionChecker.waitUntilReady()); + verify(mockClient, times(1)).checkConnection(); + } + + @Test + void testWaitUntilReadySuccessAfterRetries() { + ConnectionChecker checker = new ConnectionChecker(() -> mockClient, 100L, 3); + + when(mockClient.checkConnection()).thenReturn(mockConnectionResult); + when(mockConnectionResult.isConnected()) + .thenReturn(false) // First attempt fails + .thenReturn(false) // Second attempt fails + .thenReturn(true); // Third attempt succeeds + + assertTrue(checker.waitUntilReady()); + verify(mockClient, times(3)).checkConnection(); + } + + @Test + void testWaitUntilReadyFailsAfterMaxAttempts() { + ConnectionChecker checker = new ConnectionChecker(() -> mockClient, 100L, 2); + + when(mockClient.checkConnection()).thenThrow(new FailedRequestException("Connection failed")); + + RuntimeException exception = assertThrows(RuntimeException.class, + () -> checker.waitUntilReady()); + + assertTrue(exception.getMessage().contains("Failed to connect to MarkLogic after 2 attempts")); + verify(mockClient, times(2)).checkConnection(); + } + + @Test + void testDefaultValues() { + assertEquals(3000L, connectionChecker.getWaitInterval()); + assertEquals(20, connectionChecker.getMaxAttempts()); + } + + @Test + void testImmutability() { + // Test that objects are immutable - getters return the values set in constructor + ConnectionChecker checker1 = new ConnectionChecker(() -> mockClient); + assertEquals(3000L, checker1.getWaitInterval()); + assertEquals(20, checker1.getMaxAttempts()); + + ConnectionChecker checker2 = new ConnectionChecker(() -> mockClient, 5000L, 30); + assertEquals(5000L, checker2.getWaitInterval()); + assertEquals(30, checker2.getMaxAttempts()); + + // Original checker should still have default values + assertEquals(3000L, checker1.getWaitInterval()); + assertEquals(20, checker1.getMaxAttempts()); + } +}