diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 3ab798ef012..24ccc4255c4 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -24,8 +24,70 @@ on:
types: [opened, reopened, synchronize]
paths-ignore:
- '**.md'
+ workflow_dispatch:
+permissions:
+ contents: read
+ pull-requests: read
jobs:
+ detect-raft-changes:
+ name: "detect-raft-changes"
+ if: ${{ github.event_name == 'pull_request' }}
+ runs-on: ubuntu-latest
+ outputs:
+ raft: ${{ steps.filter.outputs.raft }}
+ steps:
+ - name: "Detect raft-related changes"
+ id: filter
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
+ GITHUB_API_URL: ${{ github.api_url }}
+ GITHUB_REPOSITORY: ${{ github.repository }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ run: |
+ python <<'PY'
+ import json
+ import os
+ import urllib.request
+
+ api_url = os.environ["GITHUB_API_URL"]
+ repository = os.environ["GITHUB_REPOSITORY"]
+ pr_number = os.environ["PR_NUMBER"]
+ token = os.environ["GITHUB_TOKEN"]
+ output_path = os.environ["GITHUB_OUTPUT"]
+ prefixes = (
+ "server/",
+ "discovery/",
+ "distribution/",
+ "test-suite/",
+ "script/",
+ ".github/workflows/",
+ )
+
+ page = 1
+ raft_changed = False
+ while True:
+ request = urllib.request.Request(
+ f"{api_url}/repos/{repository}/pulls/{pr_number}/files?per_page=100&page={page}",
+ headers={
+ "Accept": "application/vnd.github+json",
+ "Authorization": f"Bearer {token}",
+ "X-GitHub-Api-Version": "2022-11-28",
+ },
+ )
+ with urllib.request.urlopen(request) as response:
+ files = json.load(response)
+ if not files:
+ break
+ if any(file_info["filename"].startswith(prefixes) for file_info in files):
+ raft_changed = True
+ break
+ page += 1
+
+ with open(output_path, "a", encoding="utf-8") as output:
+ output.write(f"raft={'true' if raft_changed else 'false'}\n")
+ PY
+
# job 1: Test based on java 8, 17, 21 and 25.
build:
name: "build"
@@ -153,3 +215,136 @@ jobs:
-Prelease-seata \
-DskipTests \
-e -B -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn
+
+ raft-current-server-current-client:
+ name: "raft-current-server-current-client"
+ needs: detect-raft-changes
+ if: ${{ github.event_name == 'pull_request' && needs.detect-raft-changes.outputs.raft == 'true' }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: "Checkout"
+ uses: actions/checkout@v3
+ - name: "Set raft workspace"
+ run: echo "RAFT_WORKSPACE=${RUNNER_TEMP}/raft-current-server-current-client" >> "$GITHUB_ENV"
+ - name: "Set up Java JDK 25"
+ uses: actions/setup-java@v3.12.0
+ with:
+ distribution: 'zulu'
+ java-version: 25
+ cache: 'maven'
+ - name: "Prepare scripts"
+ run: chmod +x ./script/ci/raft-cluster.sh
+ - name: "Build current server-only distribution"
+ run: |
+ ./mvnw -pl distribution -am -Prelease-seata-server -DskipTests package \
+ -e -B -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn
+ echo "SEATA_CURRENT_SERVER_ARCHIVE=$(ls distribution/target/apache-seata-server-*-bin.tar.gz | head -n 1)" >> "$GITHUB_ENV"
+ - name: "Start current raft cluster"
+ run: |
+ ./script/ci/raft-cluster.sh start \
+ --distribution "$SEATA_CURRENT_SERVER_ARCHIVE" \
+ --workspace "$RAFT_WORKSPACE" \
+ --env-file "$GITHUB_ENV"
+ - name: "Run current client raft compatibility test"
+ run: |
+ ./mvnw -pl test-suite/test-new-version -am \
+ -DfailIfNoTests=false -Dtest=ExternalRaftClusterIT test \
+ -e -B -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn
+ - name: "Stop current raft cluster"
+ if: always()
+ run: ./script/ci/raft-cluster.sh stop --workspace "$RAFT_WORKSPACE"
+ - name: "Upload current/current raft logs"
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: raft-current-server-current-client-logs
+ path: ${{ env.RAFT_WORKSPACE }}
+ if-no-files-found: ignore
+
+ raft-previous-server-current-client:
+ name: "raft-previous-server-current-client"
+ needs: detect-raft-changes
+ if: ${{ github.event_name == 'pull_request' && needs.detect-raft-changes.outputs.raft == 'true' }}
+ runs-on: ubuntu-latest
+ env:
+ SEATA_PREVIOUS_RAFT_VERSION: 2.6.0
+ steps:
+ - name: "Checkout"
+ uses: actions/checkout@v3
+ - name: "Set raft workspace"
+ run: echo "RAFT_WORKSPACE=${RUNNER_TEMP}/raft-previous-server-current-client" >> "$GITHUB_ENV"
+ - name: "Set up Java JDK 25"
+ uses: actions/setup-java@v3.12.0
+ with:
+ distribution: 'zulu'
+ java-version: 25
+ cache: 'maven'
+ - name: "Prepare scripts"
+ run: chmod +x ./script/ci/raft-cluster.sh
+ - name: "Start previous stable raft cluster"
+ run: |
+ ./script/ci/raft-cluster.sh start \
+ --distribution "$SEATA_PREVIOUS_RAFT_VERSION" \
+ --workspace "$RAFT_WORKSPACE" \
+ --readiness-mode tcp \
+ --env-file "$GITHUB_ENV"
+ - name: "Run current client against previous raft server"
+ run: |
+ ./mvnw -pl test-suite/test-new-version -am \
+ -DfailIfNoTests=false -Dtest=PreviousServerCurrentClientCompatibilityIT test \
+ -e -B -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn
+ - name: "Stop previous raft cluster"
+ if: always()
+ run: ./script/ci/raft-cluster.sh stop --workspace "$RAFT_WORKSPACE"
+ - name: "Upload previous/current raft logs"
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: raft-previous-server-current-client-logs
+ path: ${{ env.RAFT_WORKSPACE }}
+ if-no-files-found: ignore
+
+ raft-current-server-previous-client:
+ name: "raft-current-server-previous-client"
+ needs: detect-raft-changes
+ if: ${{ github.event_name == 'pull_request' && needs.detect-raft-changes.outputs.raft == 'true' }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: "Checkout"
+ uses: actions/checkout@v3
+ - name: "Set raft workspace"
+ run: echo "RAFT_WORKSPACE=${RUNNER_TEMP}/raft-current-server-previous-client" >> "$GITHUB_ENV"
+ - name: "Set up Java JDK 25"
+ uses: actions/setup-java@v3.12.0
+ with:
+ distribution: 'zulu'
+ java-version: 25
+ cache: 'maven'
+ - name: "Prepare scripts"
+ run: chmod +x ./script/ci/raft-cluster.sh
+ - name: "Build current server-only distribution"
+ run: |
+ ./mvnw -pl distribution -am -Prelease-seata-server -DskipTests package \
+ -e -B -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn
+ echo "SEATA_CURRENT_SERVER_ARCHIVE=$(ls distribution/target/apache-seata-server-*-bin.tar.gz | head -n 1)" >> "$GITHUB_ENV"
+ - name: "Start current raft cluster"
+ run: |
+ ./script/ci/raft-cluster.sh start \
+ --distribution "$SEATA_CURRENT_SERVER_ARCHIVE" \
+ --workspace "$RAFT_WORKSPACE" \
+ --env-file "$GITHUB_ENV"
+ - name: "Run previous stable client against current raft server"
+ run: |
+ ./mvnw -pl test-suite/test-previous-version -am \
+ -DfailIfNoTests=false -Dtest=PreviousVersionRaftCompatibilityIT test \
+ -e -B -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn
+ - name: "Stop current raft cluster"
+ if: always()
+ run: ./script/ci/raft-cluster.sh stop --workspace "$RAFT_WORKSPACE"
+ - name: "Upload current/previous raft logs"
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: raft-current-server-previous-client-logs
+ path: ${{ env.RAFT_WORKSPACE }}
+ if-no-files-found: ignore
diff --git a/.github/workflows/raft-compatibility.yml b/.github/workflows/raft-compatibility.yml
new file mode 100644
index 00000000000..92f8906038d
--- /dev/null
+++ b/.github/workflows/raft-compatibility.yml
@@ -0,0 +1,160 @@
+#
+# 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.
+#
+name: "raft-compatibility"
+
+on:
+ push:
+ branches: [ 2.x, develop, master ]
+ paths:
+ - 'server/**'
+ - 'discovery/**'
+ - 'distribution/**'
+ - 'test-suite/**'
+ - 'script/**'
+ - '.github/workflows/**'
+ workflow_dispatch:
+
+env:
+ SEATA_PREVIOUS_RAFT_VERSION: 2.6.0
+permissions:
+ contents: read
+
+jobs:
+ raft-current-server-current-client:
+ name: "raft-current-server-current-client"
+ runs-on: ubuntu-latest
+ steps:
+ - name: "Checkout"
+ uses: actions/checkout@v3
+ - name: "Set raft workspace"
+ run: echo "RAFT_WORKSPACE=${RUNNER_TEMP}/raft-current-server-current-client" >> "$GITHUB_ENV"
+ - name: "Set up Java JDK 25"
+ uses: actions/setup-java@v3.12.0
+ with:
+ distribution: 'zulu'
+ java-version: 25
+ cache: 'maven'
+ - name: "Prepare scripts"
+ run: chmod +x ./script/ci/raft-cluster.sh
+ - name: "Build current server-only distribution"
+ run: |
+ ./mvnw -pl distribution -am -Prelease-seata-server -DskipTests package \
+ -e -B -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn
+ echo "SEATA_CURRENT_SERVER_ARCHIVE=$(ls distribution/target/apache-seata-server-*-bin.tar.gz | head -n 1)" >> "$GITHUB_ENV"
+ - name: "Start current raft cluster"
+ run: |
+ ./script/ci/raft-cluster.sh start \
+ --distribution "$SEATA_CURRENT_SERVER_ARCHIVE" \
+ --workspace "$RAFT_WORKSPACE" \
+ --env-file "$GITHUB_ENV"
+ - name: "Run current client raft compatibility test"
+ run: |
+ ./mvnw -pl test-suite/test-new-version -am \
+ -DfailIfNoTests=false -Dtest=ExternalRaftClusterIT test \
+ -e -B -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn
+ - name: "Stop current raft cluster"
+ if: always()
+ run: ./script/ci/raft-cluster.sh stop --workspace "$RAFT_WORKSPACE"
+ - name: "Upload current/current raft logs"
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: raft-current-server-current-client-logs
+ path: ${{ env.RAFT_WORKSPACE }}
+ if-no-files-found: ignore
+
+ raft-previous-server-current-client:
+ name: "raft-previous-server-current-client"
+ runs-on: ubuntu-latest
+ steps:
+ - name: "Checkout"
+ uses: actions/checkout@v3
+ - name: "Set raft workspace"
+ run: echo "RAFT_WORKSPACE=${RUNNER_TEMP}/raft-previous-server-current-client" >> "$GITHUB_ENV"
+ - name: "Set up Java JDK 25"
+ uses: actions/setup-java@v3.12.0
+ with:
+ distribution: 'zulu'
+ java-version: 25
+ cache: 'maven'
+ - name: "Prepare scripts"
+ run: chmod +x ./script/ci/raft-cluster.sh
+ - name: "Start previous stable raft cluster"
+ run: |
+ ./script/ci/raft-cluster.sh start \
+ --distribution "$SEATA_PREVIOUS_RAFT_VERSION" \
+ --workspace "$RAFT_WORKSPACE" \
+ --readiness-mode tcp \
+ --env-file "$GITHUB_ENV"
+ - name: "Run current client against previous raft server"
+ run: |
+ ./mvnw -pl test-suite/test-new-version -am \
+ -DfailIfNoTests=false -Dtest=PreviousServerCurrentClientCompatibilityIT test \
+ -e -B -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn
+ - name: "Stop previous raft cluster"
+ if: always()
+ run: ./script/ci/raft-cluster.sh stop --workspace "$RAFT_WORKSPACE"
+ - name: "Upload previous/current raft logs"
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: raft-previous-server-current-client-logs
+ path: ${{ env.RAFT_WORKSPACE }}
+ if-no-files-found: ignore
+
+ raft-current-server-previous-client:
+ name: "raft-current-server-previous-client"
+ runs-on: ubuntu-latest
+ steps:
+ - name: "Checkout"
+ uses: actions/checkout@v3
+ - name: "Set raft workspace"
+ run: echo "RAFT_WORKSPACE=${RUNNER_TEMP}/raft-current-server-previous-client" >> "$GITHUB_ENV"
+ - name: "Set up Java JDK 25"
+ uses: actions/setup-java@v3.12.0
+ with:
+ distribution: 'zulu'
+ java-version: 25
+ cache: 'maven'
+ - name: "Prepare scripts"
+ run: chmod +x ./script/ci/raft-cluster.sh
+ - name: "Build current server-only distribution"
+ run: |
+ ./mvnw -pl distribution -am -Prelease-seata-server -DskipTests package \
+ -e -B -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn
+ echo "SEATA_CURRENT_SERVER_ARCHIVE=$(ls distribution/target/apache-seata-server-*-bin.tar.gz | head -n 1)" >> "$GITHUB_ENV"
+ - name: "Start current raft cluster"
+ run: |
+ ./script/ci/raft-cluster.sh start \
+ --distribution "$SEATA_CURRENT_SERVER_ARCHIVE" \
+ --workspace "$RAFT_WORKSPACE" \
+ --env-file "$GITHUB_ENV"
+ - name: "Run previous stable client against current raft server"
+ run: |
+ ./mvnw -pl test-suite/test-previous-version -am \
+ -DfailIfNoTests=false -Dtest=PreviousVersionRaftCompatibilityIT test \
+ -e -B -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn
+ - name: "Stop current raft cluster"
+ if: always()
+ run: ./script/ci/raft-cluster.sh stop --workspace "$RAFT_WORKSPACE"
+ - name: "Upload current/previous raft logs"
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: raft-current-server-previous-client-logs
+ path: ${{ env.RAFT_WORKSPACE }}
+ if-no-files-found: ignore
diff --git a/config/seata-config-core/src/test/java/org/apache/seata/config/ProConfigurationFactoryTest.java b/config/seata-config-core/src/test/java/org/apache/seata/config/ProConfigurationFactoryTest.java
index 15e36ef6e36..2ae073cce16 100644
--- a/config/seata-config-core/src/test/java/org/apache/seata/config/ProConfigurationFactoryTest.java
+++ b/config/seata-config-core/src/test/java/org/apache/seata/config/ProConfigurationFactoryTest.java
@@ -19,7 +19,11 @@
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.ResourceLock;
+import org.junit.jupiter.api.parallel.Resources;
+@ResourceLock(Resources.SYSTEM_PROPERTIES)
+@ResourceLock("ConfigurationFactory")
class ProConfigurationFactoryTest {
@Test
@@ -28,13 +32,13 @@ void getInstance() {
System.setProperty(ConfigProperty.SYSTEM_PROPERTY_SEATA_CONFIG_NAME, ConfigProperty.REGISTRY_CONF_DEFAULT);
ConfigurationFactory.reload();
Assertions.assertEquals(
- ConfigurationFactory.CURRENT_FILE_INSTANCE.getConfig("config.file.name"), "file-test-pro.conf");
+ "file-test-pro.conf", ConfigurationFactory.CURRENT_FILE_INSTANCE.getConfig("config.file.name"));
Assertions.assertEquals(ConfigurationFactory.CURRENT_FILE_INSTANCE.getConfig("config.file.testBlank"), "");
Assertions.assertNull(ConfigurationFactory.CURRENT_FILE_INSTANCE.getConfig("config.file.testNull"));
Assertions.assertNull(ConfigurationFactory.CURRENT_FILE_INSTANCE.getConfig("config.file.testExist"));
Configuration instance = ConfigurationFactory.getInstance();
- Assertions.assertEquals(instance.getConfig("client.undo.compress.enable"), "true");
- Assertions.assertEquals(instance.getConfig("service.default.grouplist"), "127.0.0.1:8092");
+ Assertions.assertEquals("true", instance.getConfig("client.undo.compress.enable"));
+ Assertions.assertEquals("127.0.0.1:8092", instance.getConfig("service.default.grouplist"));
}
@AfterAll
diff --git a/config/seata-config-core/src/test/java/org/apache/seata/config/RegistryConfigurationFactoryTest.java b/config/seata-config-core/src/test/java/org/apache/seata/config/RegistryConfigurationFactoryTest.java
index 2ebb9b92110..a3775f17479 100644
--- a/config/seata-config-core/src/test/java/org/apache/seata/config/RegistryConfigurationFactoryTest.java
+++ b/config/seata-config-core/src/test/java/org/apache/seata/config/RegistryConfigurationFactoryTest.java
@@ -19,7 +19,11 @@
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.ResourceLock;
+import org.junit.jupiter.api.parallel.Resources;
+@ResourceLock(Resources.SYSTEM_PROPERTIES)
+@ResourceLock("ConfigurationFactory")
class RegistryConfigurationFactoryTest {
@Test
@@ -28,9 +32,9 @@ void getInstance() {
System.setProperty(ConfigProperty.SYSTEM_PROPERTY_SEATA_CONFIG_NAME, ConfigProperty.REGISTRY_CONF_DEFAULT);
ConfigurationFactory.reload();
Assertions.assertEquals(
- ConfigurationFactory.CURRENT_FILE_INSTANCE.getConfig("config.file.name"), "file-test.conf");
+ "file-test.conf", ConfigurationFactory.CURRENT_FILE_INSTANCE.getConfig("config.file.name"));
Configuration instance = ConfigurationFactory.getInstance();
- Assertions.assertEquals(instance.getConfig("service.default.grouplist"), "127.0.0.1:8091");
+ Assertions.assertEquals("127.0.0.1:8091", instance.getConfig("service.default.grouplist"));
}
@AfterAll
diff --git a/config/seata-config-core/src/test/java/org/apache/seata/config/YamlConfigurationFactoryTest.java b/config/seata-config-core/src/test/java/org/apache/seata/config/YamlConfigurationFactoryTest.java
index 696c035a704..162dda52767 100644
--- a/config/seata-config-core/src/test/java/org/apache/seata/config/YamlConfigurationFactoryTest.java
+++ b/config/seata-config-core/src/test/java/org/apache/seata/config/YamlConfigurationFactoryTest.java
@@ -19,7 +19,11 @@
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.ResourceLock;
+import org.junit.jupiter.api.parallel.Resources;
+@ResourceLock(Resources.SYSTEM_PROPERTIES)
+@ResourceLock("ConfigurationFactory")
class YamlConfigurationFactoryTest {
@Test
@@ -28,13 +32,13 @@ public void getInstance() {
System.setProperty(ConfigProperty.SYSTEM_PROPERTY_SEATA_CONFIG_NAME, ConfigProperty.REGISTRY_CONF_DEFAULT);
ConfigurationFactory.reload();
Assertions.assertEquals(
- ConfigurationFactory.CURRENT_FILE_INSTANCE.getConfig("config.file.name"), "file-test-yaml.conf");
- Assertions.assertEquals(ConfigurationFactory.CURRENT_FILE_INSTANCE.getConfig("config.file.testBlank"), "");
+ "file-test-yaml.conf", ConfigurationFactory.CURRENT_FILE_INSTANCE.getConfig("config.file.name"));
+ Assertions.assertEquals("", ConfigurationFactory.CURRENT_FILE_INSTANCE.getConfig("config.file.testBlank"));
Assertions.assertNull(ConfigurationFactory.CURRENT_FILE_INSTANCE.getConfig("config.file.testNull"));
Assertions.assertNull(ConfigurationFactory.CURRENT_FILE_INSTANCE.getConfig("config.file.testExist"));
Configuration instance = ConfigurationFactory.getInstance();
- Assertions.assertEquals(instance.getConfig("transport.heartbeat"), "true");
- Assertions.assertEquals(instance.getConfig("service.default.grouplist"), "127.0.0.1:8093");
+ Assertions.assertEquals("true", instance.getConfig("transport.heartbeat"));
+ Assertions.assertEquals("127.0.0.1:8093", instance.getConfig("service.default.grouplist"));
}
@AfterAll
diff --git a/distribution/pom.xml b/distribution/pom.xml
index ba54056bb8b..57bcceb1028 100644
--- a/distribution/pom.xml
+++ b/distribution/pom.xml
@@ -80,6 +80,48 @@
+
+ release-seata-server
+
+ false
+
+
+
+ org.apache.seata
+ seata-server
+ ${project.version}
+
+
+ org.apache.seata
+ apm-seata-skywalking-plugin
+ ${project.version}
+
+
+
+ apache-seata-server
+
+
+ org.apache.maven.plugins
+ maven-assembly-plugin
+ ${maven-assembly-plugin.version}
+
+
+ release-seata-server.xml
+
+
+
+
+ make-server-assembly
+ package
+
+ single
+
+
+
+
+
+
+
diff --git a/distribution/release-seata-server.xml b/distribution/release-seata-server.xml
new file mode 100644
index 00000000000..d68dce58242
--- /dev/null
+++ b/distribution/release-seata-server.xml
@@ -0,0 +1,137 @@
+
+
+
+ ${project.version}-bin
+ true
+ apache-seata-server-${project.version}-bin
+
+ dir
+ tar.gz
+
+
+
+
+
+ plugins/**
+
+
+
+
+
+ bin/seata-server.bat
+ bin/seata-server.sh
+ bin/seata-setup.sh
+
+ seata-server/
+ 0755
+
+
+
+
+ licenses/*
+
+ seata-server/
+
+
+
+ ../server/src/main/resources/logback/
+ seata-server/conf/logback/
+
+ **/*.xml
+
+
+
+
+ ../ext/apm-seata-skywalking-plugin/target/ext/skywalking-agent/
+ seata-server/ext/apm-skywalking
+
+
+
+ ../server/target/lib/
+ seata-server/lib
+
+
+
+ ../script/
+ seata-server/script
+
+ **/server/
+ **/logstash/
+ **/config-center/
+
+
+
+
+
+
+ docker/server/Dockerfile
+ Dockerfile
+ seata-server/
+
+
+ LICENSE-server
+ LICENSE
+ seata-server/
+
+
+ ../DISCLAIMER
+ DISCLAIMER
+
+
+ NOTICE-server
+ NOTICE
+ seata-server/
+
+
+ ../server/target/seata-server-exec.jar
+ seata-server/target/
+ seata-server.jar
+
+
+ ../server/src/main/resources/application.yml
+ seata-server/conf/
+
+
+ ../server/src/main/resources/application.example.yml
+ seata-server/conf/
+
+
+ ../server/src/main/resources/application.raft.example.yml
+ seata-server/conf/
+
+
+ ../server/src/main/resources/logback-spring.xml
+ seata-server/conf/
+
+
+ NOTICE.md
+ seata-server/lib/jdbc/
+
+
+
+
+
+ true
+
+ org.apache.seata:seata-server
+
+
+
+
diff --git a/pom.xml b/pom.xml
index 05129717f49..f8e38cd4410 100644
--- a/pom.xml
+++ b/pom.xml
@@ -61,6 +61,7 @@
integration-tx-api
test-suite/test-new-version
+ test-suite/test-previous-version
test-suite/test-old-version
test-suite/seata-benchmark-cli
json-common
@@ -373,6 +374,7 @@
distribution
test-suite/test-new-version
+ test-suite/test-previous-version
test-suite/test-old-version
diff --git a/script/ci/raft-cluster.sh b/script/ci/raft-cluster.sh
new file mode 100755
index 00000000000..ddf30bc3312
--- /dev/null
+++ b/script/ci/raft-cluster.sh
@@ -0,0 +1,406 @@
+#!/usr/bin/env bash
+#
+# 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.
+#
+
+set -euo pipefail
+
+usage() {
+ cat < --workspace [--env-file ] [--group ]
+ $0 stop --workspace
+USAGE
+}
+
+command=${1:-}
+if [[ -z "$command" ]]; then
+ usage
+ exit 1
+fi
+shift || true
+
+distribution=""
+workspace=""
+env_file=""
+group="default"
+readiness_mode="metadata"
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --distribution)
+ distribution="$2"
+ shift 2
+ ;;
+ --workspace)
+ workspace="$2"
+ shift 2
+ ;;
+ --env-file)
+ env_file="$2"
+ shift 2
+ ;;
+ --group)
+ group="$2"
+ shift 2
+ ;;
+ --readiness-mode)
+ readiness_mode="$2"
+ shift 2
+ ;;
+ *)
+ echo "Unknown argument: $1" >&2
+ usage
+ exit 1
+ ;;
+ esac
+done
+
+require_workspace() {
+ if [[ -z "$workspace" ]]; then
+ echo "--workspace is required" >&2
+ exit 1
+ fi
+}
+
+resolve_distribution_url() {
+ local version="$1"
+ local candidates=(
+ "https://github.com/apache/incubator-seata/releases/download/v${version}/apache-seata-${version}-bin.tar.gz"
+ "https://github.com/apache/incubator-seata/releases/download/v${version}/apache-seata-${version}-incubating-bin.tar.gz"
+ "https://archive.apache.org/dist/incubator/seata/${version}/apache-seata-${version}-bin.tar.gz"
+ "https://archive.apache.org/dist/incubator/seata/${version}/apache-seata-${version}-incubating-bin.tar.gz"
+ )
+ local candidate
+ for candidate in "${candidates[@]}"; do
+ if curl -fsIL "$candidate" >/dev/null 2>&1; then
+ echo "$candidate"
+ return 0
+ fi
+ done
+ echo "Unable to resolve distribution archive for version ${version}" >&2
+ return 1
+}
+
+download_distribution() {
+ local input="$1"
+ local downloads_dir="$workspace/downloads"
+ mkdir -p "$downloads_dir"
+
+ if [[ -f "$input" ]]; then
+ echo "$input"
+ return 0
+ fi
+
+ local source="$input"
+ if [[ ! "$input" =~ ^https?:// ]]; then
+ if ! source="$(resolve_distribution_url "$input")"; then
+ exit 1
+ fi
+ fi
+
+ local filename
+ filename="$(basename "${source%%\?*}")"
+ local target="$downloads_dir/$filename"
+ curl -fsSL "$source" -o "$target"
+ echo "$target"
+}
+
+extract_distribution() {
+ local archive="$1"
+ local extract_dir="$workspace/distribution"
+ rm -rf "$extract_dir"
+ mkdir -p "$extract_dir"
+ case "$archive" in
+ *.tar.gz|*.tgz)
+ tar -xzf "$archive" -C "$extract_dir"
+ ;;
+ *.zip)
+ unzip -q "$archive" -d "$extract_dir"
+ ;;
+ *)
+ echo "Unsupported distribution format: $archive" >&2
+ exit 1
+ ;;
+ esac
+}
+
+locate_server_home() {
+ local server_home
+ server_home="$(find "$workspace/distribution" -maxdepth 4 -type d -name seata-server | head -n 1)"
+ if [[ -z "$server_home" ]]; then
+ echo "Unable to locate seata-server home under $workspace/distribution" >&2
+ exit 1
+ fi
+ if [[ ! -f "$server_home/target/seata-server.jar" ]]; then
+ echo "Unable to locate seata-server.jar under $server_home/target" >&2
+ exit 1
+ fi
+ echo "$server_home"
+}
+
+detect_host_ip() {
+ local host_ip
+ host_ip="$(hostname -I | awk '{print $1}')"
+ if [[ -z "$host_ip" ]]; then
+ echo "Unable to determine non-loopback host IP" >&2
+ exit 1
+ fi
+ echo "$host_ip"
+}
+
+write_cluster_env() {
+ local target_file="$1"
+ local control_csv="$2"
+ local metadata_csv="$3"
+ local tx_csv="$4"
+ local leader_control="$5"
+ local leader_tx="$6"
+ local leader_term="$7"
+ cat >> "$target_file" <&2
+ exit 1
+ fi
+ require_workspace
+
+ rm -rf "$workspace"
+ mkdir -p "$workspace"
+
+ local archive
+ archive="$(download_distribution "$distribution")"
+ extract_distribution "$archive"
+
+ local server_home
+ server_home="$(locate_server_home)"
+ local pids_file="$workspace/pids"
+ : > "$pids_file"
+ local host_ip
+ host_ip="$(detect_host_ip)"
+
+ local -a controls tx_ports internal_ports
+ controls=(7091 7092 7093)
+ tx_ports=(8091 8092 8093)
+ internal_ports=(9091 9092 9093)
+
+ local internal_csv control_csv metadata_csv tx_csv
+ internal_csv="${host_ip}:${internal_ports[0]},${host_ip}:${internal_ports[1]},${host_ip}:${internal_ports[2]}"
+ control_csv="${host_ip}:${controls[0]},${host_ip}:${controls[1]},${host_ip}:${controls[2]}"
+ tx_csv="${host_ip}:${tx_ports[0]},${host_ip}:${tx_ports[1]},${host_ip}:${tx_ports[2]}"
+ metadata_csv="$control_csv"
+
+ local i
+ for i in 1 2 3; do
+ local index=$((i - 1))
+ local node_dir="$workspace/node-$i"
+ mkdir -p "$node_dir/conf" "$node_dir/data" "$node_dir/logs"
+ cat > "$node_dir/conf/application.yml" < "$node_dir/logs/server.out" 2>&1 &
+ echo "$!" >> "$pids_file"
+ echo "$!" > "$node_dir/pid"
+ done
+
+ local leader_control="" leader_tx="" leader_term="0"
+ case "$readiness_mode" in
+ metadata)
+ wait_for_cluster "$metadata_csv"
+ leader_control="$(python3 - "$workspace/cluster-metadata.json" <<'PY'
+import json
+import sys
+with open(sys.argv[1], 'r', encoding='utf-8') as handle:
+ payload = json.load(handle)
+leader = next(node for node in payload['nodes'] if str(node.get('role', '')).upper() == 'LEADER')
+print(f"{leader['control']['host']}:{leader['control']['port']}")
+PY
+ )"
+ leader_tx="$(python3 - "$workspace/cluster-metadata.json" <<'PY'
+import json
+import sys
+with open(sys.argv[1], 'r', encoding='utf-8') as handle:
+ payload = json.load(handle)
+leader = next(node for node in payload['nodes'] if str(node.get('role', '')).upper() == 'LEADER')
+print(f"{leader['transaction']['host']}:{leader['transaction']['port']}")
+PY
+ )"
+ leader_term="$(python3 - "$workspace/cluster-metadata.json" <<'PY'
+import json
+import sys
+with open(sys.argv[1], 'r', encoding='utf-8') as handle:
+ payload = json.load(handle)
+print(payload.get('term', 0))
+PY
+ )"
+ ;;
+ tcp)
+ wait_for_tx_ports "$tx_csv"
+ ;;
+ *)
+ echo "Unsupported readiness mode: $readiness_mode" >&2
+ exit 1
+ ;;
+ esac
+
+ if [[ -n "$env_file" ]]; then
+ write_cluster_env "$env_file" "$control_csv" "$metadata_csv" "$tx_csv" "$leader_control" "$leader_tx" "$leader_term"
+ else
+ write_cluster_env /dev/stdout "$control_csv" "$metadata_csv" "$tx_csv" "$leader_control" "$leader_tx" "$leader_term"
+ fi
+}
+
+stop_cluster() {
+ require_workspace
+ local pids_file="$workspace/pids"
+ if [[ -f "$pids_file" ]]; then
+ while read -r pid; do
+ if [[ -n "$pid" ]] && kill -0 "$pid" >/dev/null 2>&1; then
+ kill "$pid" >/dev/null 2>&1 || true
+ sleep 1
+ if kill -0 "$pid" >/dev/null 2>&1; then
+ kill -9 "$pid" >/dev/null 2>&1 || true
+ fi
+ fi
+ done < <(tac "$pids_file")
+ fi
+}
+
+case "$command" in
+ start)
+ start_cluster
+ ;;
+ stop)
+ stop_cluster
+ ;;
+ *)
+ usage
+ exit 1
+ ;;
+esac
diff --git a/server/pom.xml b/server/pom.xml
index 9de1f407868..1c3fce22a43 100644
--- a/server/pom.xml
+++ b/server/pom.xml
@@ -105,6 +105,22 @@
org.springframework
spring-web
+
+ org.springframework
+ spring-webmvc
+
+
+ org.apache.tomcat.embed
+ tomcat-embed-core
+
+
+ org.apache.tomcat.embed
+ tomcat-embed-el
+
+
+ org.apache.tomcat.embed
+ tomcat-embed-websocket
+
org.yaml
snakeyaml
@@ -528,5 +544,42 @@
+
+ release-seata-server
+
+ 5.1.42
+ 8.0.27
+ false
+
+
+ seata-server
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+ ${spring-boot-for-server.version}
+
+ org.apache.seata.server.ServerApplication
+ ZIP
+ false
+ exec
+
+
+ null
+ null
+
+
+
+
+
+
+ repackage
+
+
+
+
+
+
+
diff --git a/server/src/main/java/org/apache/seata/server/config/ServerConfig.java b/server/src/main/java/org/apache/seata/server/config/ServerConfig.java
index 067666e1d81..34030567abc 100644
--- a/server/src/main/java/org/apache/seata/server/config/ServerConfig.java
+++ b/server/src/main/java/org/apache/seata/server/config/ServerConfig.java
@@ -16,6 +16,7 @@
*/
package org.apache.seata.server.config;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.web.ServerProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -23,6 +24,7 @@
@Configuration
public class ServerConfig {
@Bean
+ @ConditionalOnProperty(name = "spring.main.web-application-type", havingValue = "none", matchIfMissing = true)
public ServerProperties emptyServerProperties() {
return new ServerProperties();
}
diff --git a/server/src/main/java/org/apache/seata/server/controller/ClusterController.java b/server/src/main/java/org/apache/seata/server/controller/ClusterController.java
index b3f124bebc3..13c4a74c46f 100644
--- a/server/src/main/java/org/apache/seata/server/controller/ClusterController.java
+++ b/server/src/main/java/org/apache/seata/server/controller/ClusterController.java
@@ -20,19 +20,19 @@
import com.alipay.sofa.jraft.conf.Configuration;
import org.apache.seata.common.metadata.MetadataResponse;
import org.apache.seata.common.result.Result;
-import org.apache.seata.common.rpc.http.HttpContext;
import org.apache.seata.server.cluster.manager.ClusterWatcherManager;
import org.apache.seata.server.cluster.raft.RaftServerManager;
-import org.apache.seata.server.cluster.watch.Watcher;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.Map;
+import java.util.concurrent.TimeUnit;
@RestController
@RequestMapping("/metadata/v1")
@@ -63,14 +63,23 @@ public MetadataResponse cluster(String group) {
}
@PostMapping("/watch")
- public void watch(
- HttpContext context,
- @RequestBody Map groupTerms,
- @RequestParam(defaultValue = "28000") Integer timeout) {
- context.setAsync(true);
- groupTerms.forEach((group, term) -> {
- Watcher watcher = new Watcher<>(group, context, timeout, Long.parseLong(String.valueOf(term)));
- clusterWatcherManager.registryWatcher(watcher);
- });
+ public ResponseEntity watch(
+ @RequestParam Map groupTerms, @RequestParam(defaultValue = "28000") Integer timeout)
+ throws InterruptedException {
+ long deadline = System.currentTimeMillis() + timeout;
+ while (System.currentTimeMillis() < deadline) {
+ for (Map.Entry entry : groupTerms.entrySet()) {
+ if ("timeout".equals(entry.getKey())) {
+ continue;
+ }
+ long clientTerm = Long.parseLong(entry.getValue());
+ MetadataResponse metadataResponse = clusterWatcherManager.getMetadataResponse(entry.getKey());
+ if (metadataResponse.getTerm() > clientTerm) {
+ return ResponseEntity.ok().build();
+ }
+ }
+ TimeUnit.MILLISECONDS.sleep(200L);
+ }
+ return ResponseEntity.status(HttpStatus.NOT_MODIFIED).build();
}
}
diff --git a/test-suite/test-new-version/pom.xml b/test-suite/test-new-version/pom.xml
index d19007e030c..b85f102afba 100644
--- a/test-suite/test-new-version/pom.xml
+++ b/test-suite/test-new-version/pom.xml
@@ -109,6 +109,11 @@
seata-rm-datasource
${project.version}
+
+ ${project.groupId}
+ seata-discovery-raft
+ ${project.version}
+
${project.groupId}
seata-saga-spring
@@ -207,4 +212,4 @@
-
\ No newline at end of file
+
diff --git a/test-suite/test-new-version/src/test/java/org/apache/seata/integration/raft/ExternalRaftClusterIT.java b/test-suite/test-new-version/src/test/java/org/apache/seata/integration/raft/ExternalRaftClusterIT.java
new file mode 100644
index 00000000000..6c960e7661c
--- /dev/null
+++ b/test-suite/test-new-version/src/test/java/org/apache/seata/integration/raft/ExternalRaftClusterIT.java
@@ -0,0 +1,155 @@
+/*
+ * 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.seata.integration.raft;
+
+import org.apache.seata.common.ConfigurationTestHelper;
+import org.apache.seata.core.model.BranchType;
+import org.apache.seata.core.model.GlobalStatus;
+import org.apache.seata.core.model.TransactionManager;
+import org.apache.seata.core.rpc.netty.RmNettyRemotingClient;
+import org.apache.seata.core.rpc.netty.TmNettyRemotingClient;
+import org.apache.seata.discovery.registry.RegistryFactory;
+import org.apache.seata.discovery.registry.RegistryService;
+import org.apache.seata.rm.DefaultResourceManager;
+import org.apache.seata.rm.RMClient;
+import org.apache.seata.tm.DefaultTransactionManager;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.net.InetSocketAddress;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+class ExternalRaftClusterIT {
+
+ private static final String APPLICATION_ID = "raft-current-client";
+ private static final String TX_SERVICE_GROUP = "default_tx_group";
+ private static final String CLUSTER = "default";
+ private static final String METADATA_ADDRS_ENV = "SEATA_RAFT_METADATA_ADDRS";
+ private static final String LEADER_ADDR_ENV = "SEATA_RAFT_LEADER_ADDR";
+ private static final String LEADER_CONTROL_ADDR_ENV = "SEATA_RAFT_LEADER_CONTROL_ADDR";
+ private static final String LEADER_TERM_ENV = "SEATA_RAFT_TERM";
+ private static final String METADATA_MAX_AGE_KEY = "registry.raft.metadataMaxAgeMs";
+ private static final long CLIENT_FAILOVER_TIMEOUT_MS = TimeUnit.SECONDS.toMillis(45);
+
+ private static TransactionManager transactionManager;
+ private static DefaultResourceManager resourceManager;
+
+ @BeforeAll
+ static void setUp() {
+ String metadataAddresses = requiredEnv(METADATA_ADDRS_ENV);
+ ConfigurationTestHelper.putConfig("registry.type", "raft");
+ ConfigurationTestHelper.putConfig("registry.raft.serverAddr", metadataAddresses);
+ ConfigurationTestHelper.putConfig(METADATA_MAX_AGE_KEY, String.valueOf(TimeUnit.MINUTES.toMillis(2)));
+ ConfigurationTestHelper.putConfig("service.vgroupMapping." + TX_SERVICE_GROUP, CLUSTER);
+ ConfigurationTestHelper.removeConfig("service.default.grouplist");
+
+ TmNettyRemotingClient.getInstance(APPLICATION_ID, TX_SERVICE_GROUP).init();
+ RMClient.init(APPLICATION_ID, TX_SERVICE_GROUP);
+ transactionManager = new DefaultTransactionManager();
+ resourceManager = DefaultResourceManager.get();
+ }
+
+ @AfterAll
+ static void tearDown() {
+ try {
+ TmNettyRemotingClient.getInstance().destroy();
+ } catch (Throwable ignored) {
+ }
+ try {
+ RmNettyRemotingClient.getInstance().destroy();
+ } catch (Throwable ignored) {
+ }
+ ConfigurationTestHelper.removeConfig("service.default.grouplist");
+ ConfigurationTestHelper.removeConfig("service.vgroupMapping." + TX_SERVICE_GROUP);
+ ConfigurationTestHelper.removeConfig(METADATA_MAX_AGE_KEY);
+ ConfigurationTestHelper.removeConfig("registry.raft.serverAddr");
+ ConfigurationTestHelper.removeConfig("registry.type");
+ }
+
+ @Test
+ void shouldSupportCommitRollbackAndLeaderReelectionThroughRaftDiscovery() throws Exception {
+ assertTransactionRoundTrip("before-reelection");
+
+ String newLeaderAddress = RaftClusterFailoverHelper.forceLeaderReelection(
+ requiredEnv(LEADER_ADDR_ENV),
+ requiredEnv(LEADER_CONTROL_ADDR_ENV),
+ Long.parseLong(requiredEnv(LEADER_TERM_ENV)));
+ waitForClientLeaderSwitch(newLeaderAddress);
+
+ assertTransactionRoundTrip("after-reelection");
+ }
+
+ private static void assertTransactionRoundTrip(String phase) throws Exception {
+ String commitXid = transactionManager.begin(APPLICATION_ID, TX_SERVICE_GROUP, phase + "-commit", 60000);
+ long commitBranchId = registerBranch(commitXid, phase + "-commit");
+ Assertions.assertTrue(commitBranchId > 0, "Branch registration should succeed for commit flow");
+ Assertions.assertEquals(GlobalStatus.Committed, transactionManager.commit(commitXid));
+
+ String rollbackXid = transactionManager.begin(APPLICATION_ID, TX_SERVICE_GROUP, phase + "-rollback", 60000);
+ long rollbackBranchId = registerBranch(rollbackXid, phase + "-rollback");
+ Assertions.assertTrue(rollbackBranchId > 0, "Branch registration should succeed for rollback flow");
+ GlobalStatus rollbackStatus = transactionManager.rollback(rollbackXid);
+ Assertions.assertTrue(
+ rollbackStatus == GlobalStatus.Rollbacked || rollbackStatus == GlobalStatus.RollbackRetrying,
+ "Rollback should complete or enter retry state");
+ }
+
+ private static void waitForClientLeaderSwitch(String expectedLeaderAddress) throws Exception {
+ RegistryService> registryService = RegistryFactory.getInstance();
+ InetSocketAddress expectedLeader = toSocketAddress(expectedLeaderAddress);
+ long deadline = System.currentTimeMillis() + CLIENT_FAILOVER_TIMEOUT_MS;
+ while (System.currentTimeMillis() < deadline) {
+ for (InetSocketAddress candidate : registryService.aliveLookup(TX_SERVICE_GROUP)) {
+ if (sameAddress(candidate, expectedLeader)) {
+ return;
+ }
+ }
+ Thread.sleep(1000L);
+ }
+ Assertions.fail("Timed out waiting for raft registry metadata to switch to leader " + expectedLeaderAddress);
+ }
+
+ private static long registerBranch(String xid, String phase) throws Exception {
+ String suffix = phase + '-' + UUID.randomUUID();
+ return resourceManager.branchRegister(
+ BranchType.AT, "raft-discovery-resource-" + suffix, APPLICATION_ID, xid, "raft_table:" + suffix, "{}");
+ }
+
+ private static InetSocketAddress toSocketAddress(String address) {
+ int separatorIndex = address.lastIndexOf(':');
+ Assertions.assertTrue(separatorIndex > 0, "Invalid raft address: " + address);
+ return new InetSocketAddress(
+ address.substring(0, separatorIndex), Integer.parseInt(address.substring(separatorIndex + 1)));
+ }
+
+ private static boolean sameAddress(InetSocketAddress actual, InetSocketAddress expected) {
+ return actual != null
+ && expected != null
+ && actual.getPort() == expected.getPort()
+ && actual.getHostString().equals(expected.getHostString());
+ }
+
+ private static String requiredEnv(String name) {
+ String value = System.getenv(name);
+ Assertions.assertNotNull(value, name + " must be provided");
+ Assertions.assertFalse(value.trim().isEmpty(), name + " must not be blank");
+ return value.trim();
+ }
+}
diff --git a/test-suite/test-new-version/src/test/java/org/apache/seata/integration/raft/PreviousServerCurrentClientCompatibilityIT.java b/test-suite/test-new-version/src/test/java/org/apache/seata/integration/raft/PreviousServerCurrentClientCompatibilityIT.java
new file mode 100644
index 00000000000..f5746f39c49
--- /dev/null
+++ b/test-suite/test-new-version/src/test/java/org/apache/seata/integration/raft/PreviousServerCurrentClientCompatibilityIT.java
@@ -0,0 +1,105 @@
+/*
+ * 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.seata.integration.raft;
+
+import org.apache.seata.common.ConfigurationTestHelper;
+import org.apache.seata.core.model.BranchType;
+import org.apache.seata.core.model.GlobalStatus;
+import org.apache.seata.core.model.TransactionManager;
+import org.apache.seata.core.rpc.netty.RmNettyRemotingClient;
+import org.apache.seata.core.rpc.netty.TmNettyRemotingClient;
+import org.apache.seata.rm.DefaultResourceManager;
+import org.apache.seata.rm.RMClient;
+import org.apache.seata.tm.DefaultTransactionManager;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.util.UUID;
+
+class PreviousServerCurrentClientCompatibilityIT {
+
+ private static final String APPLICATION_ID = "raft-current-client";
+ private static final String TX_SERVICE_GROUP = "default_tx_group";
+ private static final String CLUSTER = "default";
+ private static final String TX_ADDRS_ENV = "SEATA_RAFT_TX_ADDRS";
+
+ private static TransactionManager transactionManager;
+ private static DefaultResourceManager resourceManager;
+
+ @BeforeAll
+ static void setUp() {
+ String txAddresses = requiredEnv(TX_ADDRS_ENV).replace(',', ';');
+ ConfigurationTestHelper.putConfig("registry.type", "file");
+ ConfigurationTestHelper.putConfig("service.vgroupMapping." + TX_SERVICE_GROUP, CLUSTER);
+ ConfigurationTestHelper.putConfig("service.default.grouplist", txAddresses);
+
+ TmNettyRemotingClient.getInstance(APPLICATION_ID, TX_SERVICE_GROUP).init();
+ RMClient.init(APPLICATION_ID, TX_SERVICE_GROUP);
+ transactionManager = new DefaultTransactionManager();
+ resourceManager = DefaultResourceManager.get();
+ }
+
+ @AfterAll
+ static void tearDown() {
+ try {
+ TmNettyRemotingClient.getInstance().destroy();
+ } catch (Throwable ignored) {
+ }
+ try {
+ RmNettyRemotingClient.getInstance().destroy();
+ } catch (Throwable ignored) {
+ }
+ ConfigurationTestHelper.removeConfig("service.default.grouplist");
+ ConfigurationTestHelper.removeConfig("service.vgroupMapping." + TX_SERVICE_GROUP);
+ ConfigurationTestHelper.removeConfig("registry.type");
+ }
+
+ @Test
+ void shouldSupportCommitAndRollbackAgainstPreviousRaftCluster() throws Exception {
+ assertTransactionRoundTrip("previous-server");
+ }
+
+ private static void assertTransactionRoundTrip(String phase) throws Exception {
+ String commitXid = transactionManager.begin(APPLICATION_ID, TX_SERVICE_GROUP, phase + "-commit", 60000);
+ long commitBranchId = registerBranch(commitXid, phase + "-commit");
+ Assertions.assertTrue(commitBranchId > 0, "Branch registration should succeed for commit flow");
+ Assertions.assertEquals(GlobalStatus.Committed, transactionManager.commit(commitXid));
+
+ String rollbackXid = transactionManager.begin(APPLICATION_ID, TX_SERVICE_GROUP, phase + "-rollback", 60000);
+ long rollbackBranchId = registerBranch(rollbackXid, phase + "-rollback");
+ Assertions.assertTrue(rollbackBranchId > 0, "Branch registration should succeed for rollback flow");
+ GlobalStatus rollbackStatus = transactionManager.rollback(rollbackXid);
+ Assertions.assertTrue(
+ rollbackStatus == GlobalStatus.Rollbacked || rollbackStatus == GlobalStatus.RollbackRetrying,
+ "Rollback should complete or enter retry state");
+ }
+
+ private static long registerBranch(String xid, String phase) throws Exception {
+ String suffix = phase + '-' + UUID.randomUUID();
+ return resourceManager.branchRegister(
+ BranchType.AT, "raft-discovery-resource-" + suffix, APPLICATION_ID, xid, "raft_table:" + suffix, "{}");
+ }
+
+ private static String requiredEnv(String name) {
+ String value = System.getenv(name);
+ Assertions.assertNotNull(value, name + " must be provided");
+ Assertions.assertFalse(value.trim().isEmpty(), name + " must not be blank");
+ return value.trim();
+ }
+}
diff --git a/test-suite/test-new-version/src/test/java/org/apache/seata/integration/raft/RaftClusterFailoverHelper.java b/test-suite/test-new-version/src/test/java/org/apache/seata/integration/raft/RaftClusterFailoverHelper.java
new file mode 100644
index 00000000000..b6301309ee9
--- /dev/null
+++ b/test-suite/test-new-version/src/test/java/org/apache/seata/integration/raft/RaftClusterFailoverHelper.java
@@ -0,0 +1,128 @@
+/*
+ * 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.seata.integration.raft;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.seata.common.metadata.MetadataResponse;
+import org.apache.seata.common.metadata.Node;
+import org.junit.jupiter.api.Assertions;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+final class RaftClusterFailoverHelper {
+
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+ private static final String GROUP_ENV = "SEATA_RAFT_GROUP";
+ private static final String WORKSPACE_ENV = "SEATA_RAFT_WORKSPACE";
+ private static final String METADATA_ADDRS_ENV = "SEATA_RAFT_METADATA_ADDRS";
+ private static final int CONTROL_PORT_BASE = 7091;
+ private static final long FAILOVER_TIMEOUT_MS = TimeUnit.SECONDS.toMillis(45);
+
+ private RaftClusterFailoverHelper() {}
+
+ static String forceLeaderReelection(String oldLeaderAddress, String oldLeaderControlAddress, long oldTerm)
+ throws Exception {
+ killLeader(readLeaderPid(requiredEnv(WORKSPACE_ENV), oldLeaderControlAddress));
+ return waitForNewLeader(oldLeaderAddress, oldLeaderControlAddress, oldTerm);
+ }
+
+ private static long readLeaderPid(String workspace, String leaderControlAddress) throws IOException {
+ int controlPort = Integer.parseInt(leaderControlAddress.substring(leaderControlAddress.lastIndexOf(':') + 1));
+ int nodeIndex = controlPort - CONTROL_PORT_BASE + 1;
+ Assertions.assertTrue(nodeIndex >= 1 && nodeIndex <= 3, "Unsupported raft control port: " + controlPort);
+ File pidFile = new File(workspace, "node-" + nodeIndex + "/pid");
+ Assertions.assertTrue(pidFile.isFile(), "Missing raft node pid file: " + pidFile.getAbsolutePath());
+ List pidLines = Files.readAllLines(pidFile.toPath(), StandardCharsets.UTF_8);
+ Assertions.assertFalse(pidLines.isEmpty(), "Empty raft node pid file: " + pidFile.getAbsolutePath());
+ return Long.parseLong(pidLines.get(0).trim());
+ }
+
+ private static void killLeader(long pid) throws Exception {
+ Process kill = new ProcessBuilder("kill", "-9", String.valueOf(pid)).start();
+ int exitCode = kill.waitFor();
+ Assertions.assertEquals(0, exitCode, "Failed to kill raft leader pid " + pid);
+ }
+
+ private static String waitForNewLeader(String oldLeaderAddress, String oldLeaderControlAddress, long oldTerm)
+ throws Exception {
+ long deadline = System.currentTimeMillis() + FAILOVER_TIMEOUT_MS;
+ MetadataResponse lastResponse = null;
+ while (System.currentTimeMillis() < deadline) {
+ for (String metadataAddress : requiredEnv(METADATA_ADDRS_ENV).split(",")) {
+ MetadataResponse response = fetchMetadata(metadataAddress.trim(), requiredEnv(GROUP_ENV));
+ if (response == null) {
+ continue;
+ }
+ lastResponse = response;
+ Node leader = response.getNodes() == null
+ ? null
+ : response.getNodes().stream()
+ .filter(node -> "LEADER".equalsIgnoreCase(String.valueOf(node.getRole())))
+ .findFirst()
+ .orElse(null);
+ if (leader == null || response.getTerm() <= oldTerm) {
+ continue;
+ }
+ String leaderAddress = leader.getTransaction().getHost() + ":"
+ + leader.getTransaction().getPort();
+ String leaderControlAddress = leader.getControl().getHost() + ":"
+ + leader.getControl().getPort();
+ if (!oldLeaderAddress.equals(leaderAddress) && !oldLeaderControlAddress.equals(leaderControlAddress)) {
+ return leaderAddress;
+ }
+ }
+ Thread.sleep(1000L);
+ }
+ Assertions.fail("Timed out waiting for raft leader re-election, last metadata: " + lastResponse);
+ return oldLeaderAddress;
+ }
+
+ private static MetadataResponse fetchMetadata(String metadataAddress, String group) throws Exception {
+ HttpURLConnection connection = null;
+ try {
+ URL url = new URL("http://" + metadataAddress + "/metadata/v1/cluster?group=" + group);
+ connection = (HttpURLConnection) url.openConnection();
+ connection.setConnectTimeout(2000);
+ connection.setReadTimeout(2000);
+ connection.setRequestMethod("GET");
+ if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) {
+ return null;
+ }
+ return OBJECT_MAPPER.readValue(connection.getInputStream(), MetadataResponse.class);
+ } catch (IOException ignored) {
+ return null;
+ } finally {
+ if (connection != null) {
+ connection.disconnect();
+ }
+ }
+ }
+
+ private static String requiredEnv(String name) {
+ String value = System.getenv(name);
+ Assertions.assertNotNull(value, name + " must be provided");
+ Assertions.assertFalse(value.trim().isEmpty(), name + " must not be blank");
+ return value.trim();
+ }
+}
diff --git a/test-suite/test-previous-version/pom.xml b/test-suite/test-previous-version/pom.xml
new file mode 100644
index 00000000000..9ed33d0dfe7
--- /dev/null
+++ b/test-suite/test-previous-version/pom.xml
@@ -0,0 +1,68 @@
+
+
+
+
+ org.apache.seata
+ seata-parent
+ ${revision}
+ ../../pom.xml
+
+ 4.0.0
+ seata-test-previous-version
+ jar
+ seata-test-previous-version
+ test for Seata previous stable 2.x client compatibility
+
+
+ 2.6.0
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-deploy-plugin
+
+ true
+
+
+
+
+
+
+
+ org.apache.seata
+ seata-all
+ ${seata.previous.version}
+
+
+ org.apache.seata
+ seata-tm
+ ${seata.previous.version}
+
+
+ org.apache.seata
+ seata-rm
+ ${seata.previous.version}
+
+
+
diff --git a/test-suite/test-previous-version/src/test/java/org/apache/seata/compatibility/raft/LegacyConfigurationHelper.java b/test-suite/test-previous-version/src/test/java/org/apache/seata/compatibility/raft/LegacyConfigurationHelper.java
new file mode 100644
index 00000000000..2f38291ecee
--- /dev/null
+++ b/test-suite/test-previous-version/src/test/java/org/apache/seata/compatibility/raft/LegacyConfigurationHelper.java
@@ -0,0 +1,48 @@
+/*
+ * 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.seata.compatibility.raft;
+
+import org.apache.seata.config.ConfigurationFactory;
+
+import java.lang.reflect.Method;
+
+final class LegacyConfigurationHelper {
+
+ private LegacyConfigurationHelper() {}
+
+ static void put(String key, String value) {
+ if (value == null) {
+ System.clearProperty(key);
+ } else {
+ System.setProperty(key, value);
+ }
+ }
+
+ static void clear(String key) {
+ System.clearProperty(key);
+ }
+
+ static void reload() {
+ try {
+ Method method = ConfigurationFactory.class.getDeclaredMethod("reload");
+ method.setAccessible(true);
+ method.invoke(null);
+ } catch (Exception e) {
+ throw new IllegalStateException("Failed to reload seata configuration", e);
+ }
+ }
+}
diff --git a/test-suite/test-previous-version/src/test/java/org/apache/seata/compatibility/raft/PreviousVersionRaftCompatibilityIT.java b/test-suite/test-previous-version/src/test/java/org/apache/seata/compatibility/raft/PreviousVersionRaftCompatibilityIT.java
new file mode 100644
index 00000000000..6048514cdd9
--- /dev/null
+++ b/test-suite/test-previous-version/src/test/java/org/apache/seata/compatibility/raft/PreviousVersionRaftCompatibilityIT.java
@@ -0,0 +1,118 @@
+/*
+ * 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.seata.compatibility.raft;
+
+import org.apache.seata.core.model.BranchType;
+import org.apache.seata.core.model.GlobalStatus;
+import org.apache.seata.core.model.TransactionManager;
+import org.apache.seata.core.rpc.netty.RmNettyRemotingClient;
+import org.apache.seata.core.rpc.netty.TmNettyRemotingClient;
+import org.apache.seata.rm.DefaultResourceManager;
+import org.apache.seata.rm.RMClient;
+import org.apache.seata.tm.DefaultTransactionManager;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.util.UUID;
+
+class PreviousVersionRaftCompatibilityIT {
+
+ private static final String APPLICATION_ID = "raft-previous-client";
+ private static final String TX_SERVICE_GROUP = "default_tx_group";
+ private static final String CLUSTER = "default";
+ private static final String LEADER_ADDRESS_ENV = "SEATA_RAFT_LEADER_ADDR";
+
+ private static TransactionManager transactionManager;
+ private static DefaultResourceManager resourceManager;
+
+ @BeforeAll
+ static void setUp() {
+ String leaderAddress = requiredEnv(LEADER_ADDRESS_ENV);
+ LegacyConfigurationHelper.put("config.type", "file");
+ LegacyConfigurationHelper.put("config.file.name", "file.conf");
+ LegacyConfigurationHelper.put("registry.type", "file");
+ LegacyConfigurationHelper.put("registry.file.name", "file.conf");
+ LegacyConfigurationHelper.put("service.vgroupMapping." + TX_SERVICE_GROUP, CLUSTER);
+ LegacyConfigurationHelper.put("service.vgroup_mapping." + TX_SERVICE_GROUP, CLUSTER);
+ LegacyConfigurationHelper.put("service." + CLUSTER + ".grouplist", leaderAddress);
+ LegacyConfigurationHelper.put("service.default.grouplist", leaderAddress);
+ LegacyConfigurationHelper.reload();
+
+ TmNettyRemotingClient.getInstance(APPLICATION_ID, TX_SERVICE_GROUP).init();
+ RMClient.init(APPLICATION_ID, TX_SERVICE_GROUP);
+ transactionManager = new DefaultTransactionManager();
+ resourceManager = DefaultResourceManager.get();
+ }
+
+ @AfterAll
+ static void tearDown() {
+ try {
+ TmNettyRemotingClient.getInstance().destroy();
+ } catch (Throwable ignored) {
+ }
+ try {
+ RmNettyRemotingClient.getInstance().destroy();
+ } catch (Throwable ignored) {
+ }
+ LegacyConfigurationHelper.clear("service.default.grouplist");
+ LegacyConfigurationHelper.clear("service." + CLUSTER + ".grouplist");
+ LegacyConfigurationHelper.clear("service.vgroup_mapping." + TX_SERVICE_GROUP);
+ LegacyConfigurationHelper.clear("service.vgroupMapping." + TX_SERVICE_GROUP);
+ LegacyConfigurationHelper.clear("registry.file.name");
+ LegacyConfigurationHelper.clear("registry.type");
+ LegacyConfigurationHelper.clear("config.file.name");
+ LegacyConfigurationHelper.clear("config.type");
+ LegacyConfigurationHelper.reload();
+ }
+
+ @Test
+ void shouldSupportCommitAndRollbackAgainstCurrentRaftLeader() throws Exception {
+ String commitXid = transactionManager.begin(APPLICATION_ID, TX_SERVICE_GROUP, "previous-client-commit", 60000);
+ long commitBranchId = registerBranch(commitXid, "commit");
+ Assertions.assertTrue(commitBranchId > 0, "Branch registration should succeed for commit flow");
+ Assertions.assertEquals(GlobalStatus.Committed, transactionManager.commit(commitXid));
+
+ String rollbackXid =
+ transactionManager.begin(APPLICATION_ID, TX_SERVICE_GROUP, "previous-client-rollback", 60000);
+ long rollbackBranchId = registerBranch(rollbackXid, "rollback");
+ Assertions.assertTrue(rollbackBranchId > 0, "Branch registration should succeed for rollback flow");
+ GlobalStatus rollbackStatus = transactionManager.rollback(rollbackXid);
+ Assertions.assertTrue(
+ rollbackStatus == GlobalStatus.Rollbacked || rollbackStatus == GlobalStatus.RollbackRetrying,
+ "Rollback should complete or enter retry state");
+ }
+
+ private static long registerBranch(String xid, String phase) throws Exception {
+ String suffix = phase + '-' + UUID.randomUUID();
+ return resourceManager.branchRegister(
+ BranchType.AT,
+ "raft-compatibility-resource-" + suffix,
+ APPLICATION_ID,
+ xid,
+ "raft_table:" + suffix,
+ "{}");
+ }
+
+ private static String requiredEnv(String name) {
+ String value = System.getenv(name);
+ Assertions.assertNotNull(value, name + " must be provided");
+ Assertions.assertFalse(value.trim().isEmpty(), name + " must not be blank");
+ return value.trim();
+ }
+}
diff --git a/test-suite/test-previous-version/src/test/resources/file.conf b/test-suite/test-previous-version/src/test/resources/file.conf
new file mode 100644
index 00000000000..faabc34d384
--- /dev/null
+++ b/test-suite/test-previous-version/src/test/resources/file.conf
@@ -0,0 +1,28 @@
+# 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.
+
+transport {
+ type = "TCP"
+ server = "NIO"
+ heartbeat = true
+ enableTmClientBatchSendRequest = false
+ enableRmClientBatchSendRequest = false
+}
+
+service {
+ vgroupMapping.default_tx_group = "default"
+ default.grouplist = "127.0.0.1:8091"
+ disableGlobalTransaction = false
+}