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 +}