This is an automated email from the ASF dual-hosted git repository.
mpochatkin pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/ignite-3.git
The following commit(s) were added to refs/heads/main by this push:
new 0a45b29c786 IGNITE-24993 Implement POC for a Compatibility Test
Framework (#5983)
0a45b29c786 is described below
commit 0a45b29c7860b49fdbbbc23967f909be0faa8385
Author: Vadim Pakhnushev <[email protected]>
AuthorDate: Mon Jun 16 15:45:29 2025 +0300
IGNITE-24993 Implement POC for a Compatibility Test Framework (#5983)
---
gradle/libs.versions.toml | 3 +
modules/compatibility-tests/README.md | 31 ++
modules/compatibility-tests/build.gradle | 107 +++++++
.../ignite/internal/CompatibilityTestBase.java | 153 +++++++++
.../org/apache/ignite/internal/IgniteCluster.java | 345 +++++++++++++++++++++
.../org/apache/ignite/internal/IgniteVersions.java | 90 ++++++
.../ignite/internal/ItCompatibilityTest.java | 49 +++
.../org/apache/ignite/internal/RunnerNode.java | 139 +++++++++
.../integrationTest/resources/igniteVersions.json | 21 ++
.../ignite/internal/util/CollectionUtils.java | 23 ++
.../java/org/apache/ignite/internal/Cluster.java | 16 +-
.../testframework/TestIgnitionManager.java | 28 +-
settings.gradle | 2 +
13 files changed, 989 insertions(+), 18 deletions(-)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 9298ba0f647..102b76b9cb4 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -90,6 +90,7 @@ otel = "1.51.0"
spring-boot = "3.5.0"
spring-data = "3.5.1"
testcontainers = "1.21.1"
+gradleToolingApi = "8.6"
#Tools
pmdTool = "6.55.0"
@@ -295,3 +296,5 @@ commons-collections4 = { module =
"org.apache.commons:commons-collections4", ver
commons-lang3 = { module = "org.apache.commons:commons-lang3", version.ref =
"commons-lang3" }
spotbugs-annotations = { module = "com.github.spotbugs:spotbugs-annotations",
version.ref = "spotbugsTool" }
+
+gradle-tooling-api = { module = "org.gradle:gradle-tooling-api", version.ref =
"gradleToolingApi" }
diff --git a/modules/compatibility-tests/README.md
b/modules/compatibility-tests/README.md
new file mode 100644
index 00000000000..d549c6cd121
--- /dev/null
+++ b/modules/compatibility-tests/README.md
@@ -0,0 +1,31 @@
+# Ignite compatibility tests
+
+This module contains tests that verify Ignite cluster upgrades.
+
+## Base test
+The `PersistenceTestBase` serves as a base compatibility test. It starts and
inits a cluster of a specified previous version, calls
+`setupBaseVersion` method with the Ignite client connected to the first node
of the cluster. Then it stops the cluster and starts it in the
+embedded mode using current sources. This is done once per class, similar to
the `ClusterPerClassIntegrationTest`. This base test is
+parameterized using the list of versions from the `versions.json` resource
file. By default the test takes two latest versions as the base
+version. If the `testAllVersions` system property is defined, then all the
versions are tested.
+
+## Describing versions
+When new version is released, add new object to the `versions` array in the
`versions.json` file like so:
+```json
+{
+ "version": "3.1.0"
+}
+```
+In case there's a need for the specific node configuration override for that
version, the `configOverrides` object can be added:
+```json
+{
+ "version": "3.0.0",
+ "configOverrides": {
+ "ignite.network.membership.scaleCube.metadataTimeout": 10000
+ }
+}
+```
+
+## Running from TeamCity
+Before executing `test` task, `resolveCompatibilityTestDependencies` task
should be started to download all necessary dependencies from the
+Maven repository.
diff --git a/modules/compatibility-tests/build.gradle
b/modules/compatibility-tests/build.gradle
new file mode 100644
index 00000000000..a80e575317b
--- /dev/null
+++ b/modules/compatibility-tests/build.gradle
@@ -0,0 +1,107 @@
+import groovy.json.JsonSlurper
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+apply from: "$rootDir/buildscripts/java-core.gradle"
+apply from: "$rootDir/buildscripts/java-integration-test.gradle"
+
+description = 'ignite-compatibility-tests'
+
+repositories {
+ // For Gradle Tooling API
+ maven { url = 'https://repo.gradle.org/gradle/libs-releases' }
+}
+
+dependencies {
+ integrationTestImplementation libs.gradle.tooling.api
+ integrationTestImplementation libs.hamcrest.core
+ integrationTestImplementation libs.awaitility
+ integrationTestImplementation libs.jackson.databind
+ integrationTestImplementation(libs.jsonpath.assert) {
+ //IDEA test runner don't apply Gradle dependency resolve strategy,
this is just not implemented
+ //So, exclude asm-core transitive dependency to protect of jar-hell.
+ exclude group: 'org.ow2.asm', module: 'asm'
+ }
+
+ integrationTestImplementation testFixtures(project(':ignite-core'))
+ integrationTestImplementation testFixtures(project(':ignite-runner'))
+ integrationTestImplementation project(':ignite-core')
+ integrationTestImplementation project(':ignite-api')
+ integrationTestImplementation project(':ignite-runner')
+ integrationTestImplementation project(':ignite-client')
+ integrationTestImplementation project(':ignite-rest-api')
+}
+
+private def resolveAllDependencies(String dependencyNotation, String...
additionalNotations) {
+ def notations = [dependencyNotation]
+ notations.addAll(additionalNotations)
+ def dependencies = notations.collect {
+ dependencies.create(it)
+ }
+ Configuration detached = configurations.detachedConfiguration(dependencies
as Dependency[])
+ detached.transitive = true
+ detached.attributes {
+ it.attribute(Category.CATEGORY_ATTRIBUTE,
objects.named(Category.class, Category.LIBRARY));
+ it.attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.class,
Usage.JAVA_RUNTIME));
+ it.attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE,
objects.named(LibraryElements.class, LibraryElements.JAR));
+ it.attribute(Bundling.BUNDLING_ATTRIBUTE,
objects.named(Bundling.class, Bundling.EXTERNAL));
+ it.attribute(
+ TargetJvmEnvironment.TARGET_JVM_ENVIRONMENT_ATTRIBUTE,
+ objects.named(TargetJvmEnvironment.class,
TargetJvmEnvironment.STANDARD_JVM)
+ )
+ }
+ println "resolving $dependencyNotation"
+ return detached.resolve() // Set<File> of *.jar
+}
+
+def resolveIgniteVersions() {
+ def versionsFile =
file("src/integrationTest/resources/igniteVersions.json")
+ def versionsJson = new JsonSlurper().parseText(versionsFile.text)
+ versionsJson.versions.each { version ->
+ versionsJson.artifacts.each { artifact ->
+ resolveAllDependencies("$artifact:$version.version")
+ }
+ }
+}
+
+// This task should be started as a separate preparation step before running
tests so that all the necessary dependencies are resolved and
+// cached locally
+tasks.register('resolveCompatibilityTestDependencies') {
+ doLast {
+ resolveIgniteVersions()
+ }
+}
+
+tasks.register('constructArgFile') {
+ doLast {
+ def depNotation = project.property('dependencyNotation')
+ def jars = resolveAllDependencies(depNotation)
+ def classPath = files(jars).asPath
+ def classPathFilePath = project.property('argFilePath')
+
+ def classPathFile = file(classPathFilePath)
+ classPathFile.withPrintWriter {
+ it.println '-classpath'
+ // Java argfile quote rules are weird. It's enough to quote spaces.
+ it.println classPath.replace(" ", "\" \"")
+ defaultJvmArgs.each { arg ->
+ it.println arg
+ }
+ }
+ }
+}
diff --git
a/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/CompatibilityTestBase.java
b/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/CompatibilityTestBase.java
new file mode 100644
index 00000000000..f47193c30cf
--- /dev/null
+++
b/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/CompatibilityTestBase.java
@@ -0,0 +1,153 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal;
+
+import static
org.apache.ignite.internal.TestDefaultProfilesNames.DEFAULT_AIMEM_PROFILE_NAME;
+import static
org.apache.ignite.internal.TestDefaultProfilesNames.DEFAULT_AIPERSIST_PROFILE_NAME;
+import static
org.apache.ignite.internal.TestDefaultProfilesNames.DEFAULT_ROCKSDB_PROFILE_NAME;
+
+import java.nio.file.Path;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.apache.ignite.Ignite;
+import org.apache.ignite.InitParametersBuilder;
+import org.apache.ignite.client.IgniteClient;
+import org.apache.ignite.internal.IgniteVersions.Version;
+import org.apache.ignite.internal.testframework.BaseIgniteAbstractTest;
+import org.apache.ignite.internal.testframework.WorkDirectory;
+import org.apache.ignite.internal.testframework.WorkDirectoryExtension;
+import org.junit.jupiter.api.TestInfo;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.TestInstance.Lifecycle;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.AfterParameterizedClassInvocation;
+import org.junit.jupiter.params.BeforeParameterizedClassInvocation;
+import org.junit.jupiter.params.Parameter;
+import org.junit.jupiter.params.ParameterizedClass;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Base class for testing cluster upgrades. Starts a cluster on an old
version, initializes it, stops it, then starts it in the
+ * embedded mode using current version.
+ */
+@ExtendWith(WorkDirectoryExtension.class)
+@TestInstance(Lifecycle.PER_CLASS)
+@ParameterizedClass
+@MethodSource("baseVersions")
+public abstract class CompatibilityTestBase extends BaseIgniteAbstractTest {
+ /** Nodes bootstrap configuration pattern. */
+ private static final String NODE_BOOTSTRAP_CFG_TEMPLATE = "ignite {\n"
+ + " network: {\n"
+ + " port: {},\n"
+ + " nodeFinder.netClusterNodes: [ {} ]\n"
+ + " },\n"
+ + " storage.profiles: {"
+ + " " + DEFAULT_AIPERSIST_PROFILE_NAME + ".engine:
aipersist, "
+ + " " + DEFAULT_AIMEM_PROFILE_NAME + ".engine: aimem, "
+ + " " + DEFAULT_ROCKSDB_PROFILE_NAME + ".engine: rocksdb"
+ + " },\n"
+ + " clientConnector.port: {},\n"
+ + " clientConnector.sendServerExceptionStackTraceToClient:
true,\n"
+ + " rest.port: {},\n"
+ + " failureHandler.dumpThreadsOnFailure: false\n"
+ + "}";
+
+ // If there are no fields annotated with @Parameter, constructor injection
will be used, which is incompatible with the
+ // Lifecycle.PER_CLASS.
+ @SuppressWarnings("unused")
+ @Parameter
+ String baseVersion;
+
+ @WorkDirectory
+ private static Path WORK_DIR;
+
+ protected IgniteCluster cluster;
+
+ @SuppressWarnings("unused")
+ @BeforeParameterizedClassInvocation
+ void startCluster(String baseVersion, TestInfo testInfo) {
+ ClusterConfiguration clusterConfiguration =
ClusterConfiguration.builder(testInfo, WORK_DIR)
+
.defaultNodeBootstrapConfigTemplate(NODE_BOOTSTRAP_CFG_TEMPLATE)
+ .build();
+
+ int nodesCount = nodesCount();
+
+ cluster = new IgniteCluster(clusterConfiguration);
+ cluster.start(baseVersion, nodesCount);
+
+ cluster.init(this::configureInitParameters);
+
+ try (IgniteClient client = cluster.createClient()) {
+ setupBaseVersion(client);
+ }
+
+ cluster.stop();
+
+ cluster.startEmbedded(nodesCount);
+ }
+
+ @SuppressWarnings("unused")
+ @AfterParameterizedClassInvocation
+ void stopCluster() {
+ if (cluster != null) {
+ cluster.stop();
+ }
+ }
+
+ protected String getNodeBootstrapConfigTemplate() {
+ return NODE_BOOTSTRAP_CFG_TEMPLATE;
+ }
+
+ protected int nodesCount() {
+ return 3;
+ }
+
+ /**
+ * This method can be overridden to add custom init parameters during
cluster initialization.
+ */
+ protected void configureInitParameters(InitParametersBuilder builder) {
+ }
+
+ protected abstract void setupBaseVersion(Ignite baseIgnite);
+
+ protected List<List<Object>> sql(String query) {
+ return ClusterPerClassIntegrationTest.sql(cluster.node(0), null, null,
null, query);
+ }
+
+ private static List<String> baseVersions() {
+ return baseVersions(2);
+ }
+
+ /**
+ * Returns a list of base versions. If {@code testAllVersions} system
property is set, then all versions are returned, otherwise, at
+ * most {@code numLatest} are taken.
+ *
+ * @param numLatest Number of latest versions to take by default.
+ * @return A list of base versions for a test.
+ */
+ protected static List<String> baseVersions(int numLatest) {
+ List<String> versions =
IgniteVersions.INSTANCE.versions().stream().map(Version::version).collect(Collectors.toList());
+ if (System.getProperty("testAllVersions") != null) {
+ return versions;
+ } else {
+ // Take at most two latest versions by default.
+ int fromIndex = Math.max(versions.size() - numLatest, 0);
+ return versions.subList(fromIndex, versions.size());
+ }
+ }
+}
diff --git
a/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/IgniteCluster.java
b/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/IgniteCluster.java
new file mode 100644
index 00000000000..df845782b20
--- /dev/null
+++
b/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/IgniteCluster.java
@@ -0,0 +1,345 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal;
+
+import static com.jayway.jsonpath.matchers.JsonPathMatchers.hasJsonPath;
+import static java.util.stream.Collectors.toList;
+import static
org.apache.ignite.internal.testframework.matchers.CompletableFutureMatcher.willCompleteSuccessfully;
+import static org.apache.ignite.internal.util.CollectionUtils.setListAtIndex;
+import static org.awaitility.Awaitility.await;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpRequest.BodyPublishers;
+import java.net.http.HttpRequest.Builder;
+import java.net.http.HttpResponse;
+import java.net.http.HttpResponse.BodyHandlers;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+import org.apache.ignite.Ignite;
+import org.apache.ignite.IgniteServer;
+import org.apache.ignite.InitParameters;
+import org.apache.ignite.InitParametersBuilder;
+import org.apache.ignite.client.IgniteClient;
+import org.apache.ignite.internal.Cluster.ServerRegistration;
+import org.apache.ignite.internal.logger.IgniteLogger;
+import org.apache.ignite.internal.logger.Loggers;
+import org.apache.ignite.internal.rest.api.cluster.InitCommand;
+import org.apache.ignite.internal.testframework.TestIgnitionManager;
+import org.gradle.tooling.GradleConnectionException;
+import org.gradle.tooling.GradleConnector;
+import org.gradle.tooling.ProjectConnection;
+import org.gradle.tooling.model.build.BuildEnvironment;
+
+/**
+ * Cluster of nodes. Can be started with nodes of previous Ignite versions
running in the external processes or in the embedded mode
+ * using current sources.
+ */
+public class IgniteCluster {
+ private static final IgniteLogger LOG =
Loggers.forClass(IgniteCluster.class);
+
+ // Embedded nodes
+ private final List<IgniteServer> igniteServers = new
CopyOnWriteArrayList<>();
+ private final List<Ignite> nodes = new CopyOnWriteArrayList<>();
+ private final HttpClient client = HttpClient.newBuilder().build();
+
+ // External process nodes
+ private List<RunnerNode> runnerNodes;
+
+ private volatile boolean started = false;
+ private volatile boolean stopped = false;
+
+ private final ClusterConfiguration clusterConfiguration;
+
+ IgniteCluster(ClusterConfiguration clusterConfiguration) {
+ this.clusterConfiguration = clusterConfiguration;
+ }
+
+ /**
+ * Starts cluster with nodes of previous version using external process.
+ *
+ * @param igniteVersion Ignite version to run the nodes with.
+ * @param nodesCount Number of nodes in the cluster.
+ */
+ public void start(String igniteVersion, int nodesCount) {
+ if (started) {
+ throw new IllegalStateException("The cluster is already started");
+ }
+
+ runnerNodes = startRunnerNodes(igniteVersion, nodesCount);
+ }
+
+ /**
+ * Starts cluster in embedded mode with nodes of current version.
+ *
+ * @param nodesCount Number of nodes in the cluster.
+ */
+ public void startEmbedded(int nodesCount) {
+ if (started) {
+ throw new IllegalStateException("The cluster is already started");
+ }
+
+ List<ServerRegistration> nodeRegistrations = new ArrayList<>();
+ for (int nodeIndex = 0; nodeIndex < nodesCount; nodeIndex++) {
+ nodeRegistrations.add(startEmbeddedNode(nodeIndex));
+ }
+
+ for (ServerRegistration registration : nodeRegistrations) {
+ assertThat(registration.registrationFuture(),
willCompleteSuccessfully());
+ }
+
+ started = true;
+ stopped = false;
+ }
+
+ /**
+ * Stops all the nodes in the cluster.
+ */
+ public void stop() {
+ List<IgniteServer> serversToStop = new ArrayList<>(igniteServers);
+
+ List<String> serverNames = serversToStop.stream()
+ .filter(Objects::nonNull)
+ .map(IgniteServer::name)
+ .collect(toList());
+ LOG.info("Shutting the embedded cluster down [nodes={}]", serverNames);
+
+ Collections.fill(igniteServers, null);
+ Collections.fill(nodes, null);
+
+
serversToStop.parallelStream().filter(Objects::nonNull).forEach(IgniteServer::shutdown);
+
+ LOG.info("Shut the embedded cluster down");
+
+ if (runnerNodes != null) {
+ runnerNodes.forEach(RunnerNode::stop);
+ runnerNodes.clear();
+ }
+
+ started = false;
+ stopped = true;
+ }
+
+ /**
+ * Initializes the cluster using REST API on the first node with default
settings.
+ */
+ void init(Consumer<InitParametersBuilder> initParametersConfigurator) {
+ init(new int[] { 0 }, initParametersConfigurator);
+ }
+
+ /**
+ * Initializes the cluster using REST API on the first node with specified
Metastorage and CMG nodes.
+ *
+ * @param cmgNodes Indices of the CMG nodes (also used as Metastorage
group).
+ */
+ void init(int[] cmgNodes, Consumer<InitParametersBuilder>
initParametersConfigurator) {
+ // Wait for the node to start accepting requests
+ await()
+ .ignoreExceptions()
+ .timeout(30, TimeUnit.SECONDS)
+ .until(
+ () -> send(get("/management/v1/node/state")).body(),
+ hasJsonPath("$.state", is(equalTo("STARTING")))
+ );
+
+ // Initialize the cluster
+ List<String> metaStorageAndCmgNodes = Arrays.stream(cmgNodes)
+ .mapToObj(this::nodeName)
+ .collect(toList());
+
+ InitParametersBuilder builder = InitParameters.builder()
+ .metaStorageNodeNames(metaStorageAndCmgNodes)
+ .clusterName(clusterConfiguration.clusterName());
+
+ initParametersConfigurator.accept(builder);
+
+ sendInitRequest(builder.build());
+
+ // Wait for the cluster to be initialized
+ await()
+ .ignoreExceptions()
+ .timeout(30, TimeUnit.SECONDS)
+ .until(
+ () -> send(get("/management/v1/node/state")).body(),
+ hasJsonPath("$.state", is(equalTo("STARTED")))
+ );
+
+ started = true;
+ stopped = false;
+ }
+
+ private void sendInitRequest(InitParameters initParameters) {
+ ObjectMapper mapper = new ObjectMapper();
+ String requestBody;
+ try {
+ InitCommand initCommand = new InitCommand(
+ initParameters.metaStorageNodeNames(),
+ initParameters.cmgNodeNames(),
+ initParameters.clusterName(),
+ initParameters.clusterConfiguration()
+ );
+ requestBody = mapper.writeValueAsString(initCommand);
+ } catch (JsonProcessingException e) {
+ throw new RuntimeException(e);
+ }
+
+ assertThat(send(post("/management/v1/cluster/init",
requestBody)).statusCode(), is(200));
+ }
+
+ /**
+ * Creates a client connection to the first node of the cluster.
+ *
+ * @return Ignite client instance.
+ */
+ IgniteClient createClient() {
+ return IgniteClient.builder().addresses("localhost:" +
clusterConfiguration.baseClientPort()).build();
+ }
+
+ /**
+ * Returns target version embedded node.
+ *
+ * @param index Node index.
+ * @return Embedded node.
+ */
+ public Ignite node(int index) {
+ return Objects.requireNonNull(nodes.get(index), "index=" + index);
+ }
+
+ /**
+ * Returns node name by index.
+ *
+ * @param nodeIndex Index of the node.
+ * @return Node name.
+ */
+ public String nodeName(int nodeIndex) {
+ return
clusterConfiguration.nodeNamingStrategy().nodeName(clusterConfiguration,
nodeIndex);
+ }
+
+ private ServerRegistration startEmbeddedNode(int nodeIndex) {
+ String nodeName = nodeName(nodeIndex);
+
+ IgniteServer node = TestIgnitionManager.start(
+ nodeName,
+ null,
+
clusterConfiguration.workDir().resolve(clusterConfiguration.clusterName()).resolve(nodeName)
+ );
+
+ synchronized (igniteServers) {
+ setListAtIndex(igniteServers, nodeIndex, node);
+ }
+
+ CompletableFuture<Void> registrationFuture =
node.waitForInitAsync().thenRun(() -> {
+ synchronized (nodes) {
+ setListAtIndex(nodes, nodeIndex, node.api());
+ }
+
+ if (stopped) {
+ // Make sure we stop even a node that finished starting after
the cluster has been stopped.
+ node.shutdown();
+ }
+ });
+
+ return new ServerRegistration(node, registrationFuture);
+ }
+
+ private List<RunnerNode> startRunnerNodes(String igniteVersion, int
nodesCount) {
+ try (ProjectConnection connection = GradleConnector.newConnector()
+ // Current directory is modules/compatibility-tests so get two
parents
+ .forProjectDirectory(Path.of("..", "..").normalize().toFile())
+ .connect()
+ ) {
+ BuildEnvironment environment =
connection.model(BuildEnvironment.class).get();
+
+ File javaHome = environment.getJava().getJavaHome();
+ File argFile = constructArgFile(connection,
"org.apache.ignite:ignite-runner:" + igniteVersion);
+
+ List<RunnerNode> result = new ArrayList<>();
+ for (int nodeIndex = 0; nodeIndex < nodesCount; nodeIndex++) {
+ result.add(RunnerNode.startNode(javaHome, argFile,
igniteVersion, clusterConfiguration, nodesCount, nodeIndex));
+ }
+
+ return result;
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static File constructArgFile(ProjectConnection connection, String
dependencyNotation) throws IOException {
+ File argFilePath = File.createTempFile("argFilePath", "");
+ argFilePath.deleteOnExit();
+
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try {
+ connection.newBuild()
+ .forTasks(":ignite-compatibility-tests:constructArgFile")
+ .withArguments(
+ "-PdependencyNotation=" + dependencyNotation,
+ "-PargFilePath=" + argFilePath
+ )
+ .setStandardOutput(baos)
+ .setStandardError(baos)
+ .run();
+ } catch (GradleConnectionException | IllegalStateException e) {
+ LOG.error("Failed to run constructArgFile task", e);
+ LOG.error("Gradle task output:" + System.lineSeparator() + baos);
+ throw new RuntimeException(e);
+ }
+
+ return argFilePath;
+ }
+
+ private HttpRequest post(String path, String body) {
+ return newBuilder(path)
+ .header("content-type", "application/json")
+ .POST(BodyPublishers.ofString(body))
+ .build();
+ }
+
+ private HttpRequest get(String path) {
+ return newBuilder(path).build();
+ }
+
+ private Builder newBuilder(String path) {
+ return HttpRequest.newBuilder(URI.create("http://localhost:" +
clusterConfiguration.baseHttpPort() + path));
+ }
+
+ private HttpResponse<String> send(HttpRequest request) {
+ try {
+ return client.send(request, BodyHandlers.ofString());
+ } catch (IOException | InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git
a/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/IgniteVersions.java
b/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/IgniteVersions.java
new file mode 100644
index 00000000000..4f8064f0d8b
--- /dev/null
+++
b/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/IgniteVersions.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import org.apache.ignite.internal.logger.Loggers;
+
+/**
+ * POJO with ignite versions data from the {@code igniteVersions.json}.
Contains a list of artifact names and a list of versions with
+ * optional node config overrides.
+ */
+@SuppressWarnings("unused")
+public class IgniteVersions {
+ public static IgniteVersions INSTANCE = readFromJson();
+
+ private List<String> artifacts;
+ private List<Version> versions;
+
+ public IgniteVersions() {
+ }
+
+ @JsonCreator
+ public IgniteVersions(@JsonProperty("artifacts") List<String> artifacts,
@JsonProperty("versions") List<Version> versions) {
+ this.artifacts = artifacts;
+ this.versions = versions;
+ }
+
+ public List<String> artifacts() {
+ return artifacts;
+ }
+
+ public List<Version> versions() {
+ return versions;
+ }
+
+ /**
+ * Represents a particular Ignite version with optional node config
overrides.
+ */
+ public static class Version {
+ private String version;
+ private Map<String, String> configOverrides;
+
+ public Version() {
+ }
+
+ @JsonCreator
+ public Version(@JsonProperty("version") String version,
@JsonProperty("configOverrides") Map<String, String> configOverrides) {
+ this.version = version;
+ this.configOverrides = configOverrides;
+ }
+
+ public String version() {
+ return version;
+ }
+
+ public Map<String, String> configOverrides() {
+ return configOverrides;
+ }
+ }
+
+ private static IgniteVersions readFromJson() {
+ ObjectMapper mapper = new ObjectMapper();
+ try {
+ return
mapper.readValue(IgniteVersions.class.getResource("/igniteVersions.json"),
IgniteVersions.class);
+ } catch (IOException e) {
+ Loggers.forClass(IgniteVersions.class).error("Failed to read
igniteVersions.json", e);
+ return new IgniteVersions();
+ }
+ }
+}
diff --git
a/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/ItCompatibilityTest.java
b/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/ItCompatibilityTest.java
new file mode 100644
index 00000000000..835d6fa7b81
--- /dev/null
+++
b/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/ItCompatibilityTest.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+
+import java.util.List;
+import org.apache.ignite.Ignite;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedClass;
+import org.junit.jupiter.params.provider.MethodSource;
+
+@ParameterizedClass
+@MethodSource("baseVersions")
+@Disabled("https://issues.apache.org/jira/browse/IGNITE-25647")
+class ItCompatibilityTest extends CompatibilityTestBase {
+ @Override
+ protected void setupBaseVersion(Ignite baseIgnite) {
+ baseIgnite.sql().execute(null, "CREATE TABLE TEST(ID INT PRIMARY KEY,
VAL VARCHAR)");
+ baseIgnite.sql().execute(null, "INSERT INTO TEST VALUES (1, 'str')");
+ }
+
+ @Test
+ void testCompatibility() {
+ List<List<Object>> result = sql("SELECT * FROM TEST");
+ assertThat(result, contains(contains(1, "str")));
+ }
+
+ private static List<String> baseVersions() {
+ return baseVersions(2);
+ }
+}
diff --git
a/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/RunnerNode.java
b/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/RunnerNode.java
new file mode 100644
index 00000000000..d10b9297822
--- /dev/null
+++
b/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/RunnerNode.java
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal;
+
+import static java.util.stream.Collectors.joining;
+import static
org.apache.ignite.internal.testframework.TestIgnitionManager.DEFAULT_CONFIG_NAME;
+import static
org.apache.ignite.internal.testframework.TestIgnitionManager.writeConfigurationFile;
+import static
org.apache.ignite.internal.testframework.TestIgnitionManager.writeConfigurationFileApplyingTestDefaults;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import org.apache.ignite.internal.IgniteVersions.Version;
+import org.apache.ignite.internal.app.IgniteRunner;
+import org.apache.ignite.internal.lang.IgniteStringFormatter;
+
+/**
+ * Represents the Ignite node running in the external process.
+ */
+public class RunnerNode {
+ private static final Map<String, Map<String, String>> DEFAULTS_PER_VERSION
= getTestDefaultsPerVersion();
+
+ private final Process process;
+
+ private RunnerNode(Process process) {
+ this.process = process;
+ }
+
+ /**
+ * Starts the Ignite in the external process.
+ *
+ * @param javaHome Path to the Java to run the node with.
+ * @param argFile Java arguments file.
+ * @param igniteVersion Version of the Ignite. Used to get the
configuration defaults.
+ * @param clusterConfiguration Test cluster configuration.
+ * @param nodesCount Overall number of nodes.
+ * @param nodeIndex Current node index.
+ * @return Instance of the control object.
+ * @throws IOException If an I/O exception occurs.
+ */
+ public static RunnerNode startNode(
+ File javaHome,
+ File argFile,
+ String igniteVersion,
+ ClusterConfiguration clusterConfiguration,
+ int nodesCount,
+ int nodeIndex
+ ) throws IOException {
+ String nodeName =
clusterConfiguration.nodeNamingStrategy().nodeName(clusterConfiguration,
nodeIndex);
+ Path workDir =
clusterConfiguration.workDir().resolve(clusterConfiguration.clusterName()).resolve(nodeName);
+ String configStr = formatConfig(clusterConfiguration, nodeIndex,
nodesCount);
+
+ Files.createDirectories(workDir);
+ Path configPath = workDir.resolve(DEFAULT_CONFIG_NAME);
+
+ boolean useTestDefaults = true;
+ if (useTestDefaults) {
+ writeConfigurationFileApplyingTestDefaults(configStr, configPath,
DEFAULTS_PER_VERSION.get(igniteVersion));
+ } else {
+ writeConfigurationFile(configStr, configPath);
+ }
+
+ Process process = executeRunner(javaHome, argFile, configPath,
workDir, nodeName);
+ return new RunnerNode(process);
+ }
+
+ /**
+ * Stops the node by killing the process.
+ */
+ public void stop() {
+ process.destroy();
+ }
+
+ private static Map<String, Map<String, String>>
getTestDefaultsPerVersion() {
+ return IgniteVersions.INSTANCE.versions().stream()
+ .filter(version -> version.configOverrides() != null)
+ .collect(Collectors.toMap(
+ Version::version,
+ Version::configOverrides
+ ));
+ }
+
+ private static String seedAddressesString(ClusterConfiguration
clusterConfiguration, int seedsCount) {
+ return IntStream.range(0, seedsCount)
+ .map(nodeIndex -> clusterConfiguration.basePort() + nodeIndex)
+ .mapToObj(port -> "\"localhost:" + port + '\"')
+ .collect(joining(", "));
+ }
+
+ private static String formatConfig(ClusterConfiguration
clusterConfiguration, int nodeIndex, int nodesCount) {
+ return IgniteStringFormatter.format(
+ clusterConfiguration.defaultNodeBootstrapConfigTemplate(),
+ clusterConfiguration.basePort() + nodeIndex,
+ seedAddressesString(clusterConfiguration, nodesCount),
+ clusterConfiguration.baseClientPort() + nodeIndex,
+ clusterConfiguration.baseHttpPort() + nodeIndex,
+ clusterConfiguration.baseHttpsPort() + nodeIndex
+ );
+ }
+
+ @SuppressWarnings("UseOfProcessBuilder")
+ private static Process executeRunner(
+ File javaHome,
+ File classPathFile,
+ Path configPath,
+ Path workDir,
+ String nodeName
+ ) throws IOException {
+ ProcessBuilder pb = new ProcessBuilder(
+ javaHome.toPath().resolve("bin").resolve("java").toString(),
+ "@" + classPathFile,
+ IgniteRunner.class.getName(),
+ "--node-name", nodeName,
+ "--work-dir", workDir.toString(),
+ "--config-path", configPath.toString()
+ );
+ pb.inheritIO();
+ return pb.start();
+ }
+}
diff --git
a/modules/compatibility-tests/src/integrationTest/resources/igniteVersions.json
b/modules/compatibility-tests/src/integrationTest/resources/igniteVersions.json
new file mode 100644
index 00000000000..2375a8de1ef
--- /dev/null
+++
b/modules/compatibility-tests/src/integrationTest/resources/igniteVersions.json
@@ -0,0 +1,21 @@
+{
+ "artifacts": [
+ "org.apache.ignite:ignite-runner"
+ ],
+ "versions": [
+ {
+ "version": "3.0.0",
+ "configOverrides": {
+ "ignite.network.membership.scaleCube.metadataTimeout": 10000,
+ "ignite.storage.profiles.default_aipersist.engine": "aipersist",
+ "ignite.storage.profiles.default_aipersist.size": 268435456,
+ "ignite.storage.profiles.default_aimem.engine": "aimem",
+ "ignite.storage.profiles.default_aimem.initSize": 268435456,
+ "ignite.storage.profiles.default_aimem.maxSize": 268435456,
+ "ignite.storage.profiles.default.engine": "aipersist",
+ "ignite.storage.profiles.default.size": 268435456,
+ "ignite.system.properties.aipersistThrottling": "disabled"
+ }
+ }
+ ]
+}
diff --git
a/modules/core/src/main/java/org/apache/ignite/internal/util/CollectionUtils.java
b/modules/core/src/main/java/org/apache/ignite/internal/util/CollectionUtils.java
index a6b92f74112..9e6aa93100e 100644
---
a/modules/core/src/main/java/org/apache/ignite/internal/util/CollectionUtils.java
+++
b/modules/core/src/main/java/org/apache/ignite/internal/util/CollectionUtils.java
@@ -18,6 +18,7 @@
package org.apache.ignite.internal.util;
import static java.util.Collections.emptyIterator;
+import static java.util.Collections.nCopies;
import static java.util.Collections.unmodifiableSet;
import static java.util.stream.Collectors.toSet;
@@ -619,4 +620,26 @@ public final class CollectionUtils {
}
};
}
+
+ /**
+ * Sets list element at the specified index. Expands a list if needed.
+ *
+ * @param list List to update.
+ * @param i Target index.
+ * @param element Element to put.
+ * @param <T> Type of the list elements.
+ */
+ public static <T> void setListAtIndex(List<T> list, int i, T element) {
+ if (list.size() < i) {
+ list.addAll(nCopies(i - list.size(), null));
+ }
+
+ if (list.size() < i + 1) {
+ list.add(element);
+ } else {
+ T prev = list.set(i, element);
+
+ assert prev == null : String.format("Found previous value %s at
index %d", prev, i);
+ }
+ }
}
diff --git
a/modules/runner/src/testFixtures/java/org/apache/ignite/internal/Cluster.java
b/modules/runner/src/testFixtures/java/org/apache/ignite/internal/Cluster.java
index 6350451d9a6..4b490f0f320 100644
---
a/modules/runner/src/testFixtures/java/org/apache/ignite/internal/Cluster.java
+++
b/modules/runner/src/testFixtures/java/org/apache/ignite/internal/Cluster.java
@@ -17,7 +17,6 @@
package org.apache.ignite.internal;
-import static java.util.Collections.nCopies;
import static java.util.concurrent.TimeUnit.SECONDS;
import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toList;
@@ -30,6 +29,7 @@ import static
org.apache.ignite.internal.lang.IgniteSystemProperties.colocationE
import static
org.apache.ignite.internal.testframework.IgniteTestUtils.waitForCondition;
import static
org.apache.ignite.internal.testframework.matchers.CompletableFutureMatcher.willCompleteSuccessfully;
import static
org.apache.ignite.internal.testframework.matchers.CompletableFutureMatcher.willSucceedIn;
+import static org.apache.ignite.internal.util.CollectionUtils.setListAtIndex;
import static
org.apache.ignite.internal.util.CompletableFutures.nullCompletedFuture;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
@@ -357,20 +357,6 @@ public class Cluster {
return clusterConfiguration.baseHttpPort() + nodeIndex;
}
- private static <T> void setListAtIndex(List<T> list, int i, T element) {
- if (list.size() < i) {
- list.addAll(nCopies(i - list.size(), null));
- }
-
- if (list.size() < i + 1) {
- list.add(element);
- } else {
- T prev = list.set(i, element);
-
- assert prev == null : String.format("Found previous value %s at
index %d", prev, i);
- }
- }
-
private String seedAddressesString() {
int localSeedCountOverride = seedCountOverride;
// We do this maxing because in some scenarios startAndInit() is not
invoked, instead startNode() is used directly.
diff --git
a/modules/runner/src/testFixtures/java/org/apache/ignite/internal/testframework/TestIgnitionManager.java
b/modules/runner/src/testFixtures/java/org/apache/ignite/internal/testframework/TestIgnitionManager.java
index 2dbdd347073..dd828cc8098 100644
---
a/modules/runner/src/testFixtures/java/org/apache/ignite/internal/testframework/TestIgnitionManager.java
+++
b/modules/runner/src/testFixtures/java/org/apache/ignite/internal/testframework/TestIgnitionManager.java
@@ -49,7 +49,7 @@ public class TestIgnitionManager {
/** Default name of configuration file. */
public static final String DEFAULT_CONFIG_NAME = "ignite-config.conf";
- private static final int DEFAULT_SCALECUBE_METADATA_TIMEOUT = 10_000;
+ public static final int DEFAULT_SCALECUBE_METADATA_TIMEOUT = 10_000;
/** Default DelayDuration in ms used for tests that is set on node init. */
public static final int DEFAULT_DELAY_DURATION_MS = 100;
@@ -172,17 +172,39 @@ public class TestIgnitionManager {
}
private static void writeConfigurationFileApplyingTestDefaults(@Nullable
String configStr, Path configPath) throws IOException {
+ writeConfigurationFileApplyingTestDefaults(configStr, configPath,
DEFAULT_NODE_CONFIG);
+ }
+
+ /**
+ * Applies overrides to the config and writes it to disk.
+ *
+ * @param configStr Config string.
+ * @param configPath Config file path.
+ * @param defaults Map of overrides.
+ * @throws IOException If failed to write the file.
+ */
+ public static void writeConfigurationFileApplyingTestDefaults(
+ @Nullable String configStr,
+ Path configPath,
+ Map<String, String> defaults
+ ) throws IOException {
if (configStr == null && Files.exists(configPath)) {
// Nothing to do.
return;
}
- String configStringToWrite = applyTestDefaultsToConfig(configStr,
DEFAULT_NODE_CONFIG);
+ String configStringToWrite = applyTestDefaultsToConfig(configStr,
defaults);
writeConfigurationFile(configStringToWrite, configPath);
}
- private static void writeConfigurationFile(@Nullable String configStr,
Path configPath) throws IOException {
+ /**
+ * Writes config to file.
+ *
+ * @param configStr Config string.
+ * @param configPath Config file path.
+ */
+ public static void writeConfigurationFile(@Nullable String configStr, Path
configPath) throws IOException {
if (configStr == null && Files.exists(configPath)) {
// Nothing to do.
return;
diff --git a/settings.gradle b/settings.gradle
index 048ac23d509..fce7bf2cff5 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -95,6 +95,7 @@ include(':ignite-configuration-root')
include(':ignite-configuration-system')
include(':ignite-system-disaster-recovery')
include(':ignite-system-disaster-recovery-api')
+include(':ignite-compatibility-tests')
project(":ignite-examples").projectDir = file('examples')
project(":ignite-dev-utilities").projectDir = file('dev-utilities')
@@ -175,6 +176,7 @@ project(":ignite-configuration-root").projectDir =
file('modules/configuration-r
project(":ignite-configuration-system").projectDir =
file('modules/configuration-system')
project(":ignite-system-disaster-recovery").projectDir =
file('modules/system-disaster-recovery')
project(":ignite-system-disaster-recovery-api").projectDir =
file('modules/system-disaster-recovery-api')
+project(':ignite-compatibility-tests').projectDir =
file('modules/compatibility-tests')
include(":migration-tools-ignite2-repack")
include(":migration-tools-ignite3-repack")