Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions regression-test/pipeline/common/github-utils.sh
Original file line number Diff line number Diff line change
Expand Up @@ -405,3 +405,34 @@ file_changed_meta() {
done
echo "Doris meta not changed" && return 1
}

github_utils__maybe_enable_external_stage_timer() {
local timer_script
local main_definition

if [[ -z "${teamcity_build_checkoutDir:-}" ]]; then
return 0
fi
timer_script="${teamcity_build_checkoutDir}/regression-test/pipeline/external/external-stage-timer.sh"
if [[ ! -f "${timer_script}" ]]; then
return 0
fi
if ! declare -F main >/dev/null; then
return 0
fi

main_definition="$(declare -f main)"
if [[ "${main_definition}" != *"START EXTERNAL DOCKER"* ]] ||
[[ "${main_definition}" != *"RUN EXTERNAL CASE"* ]] ||
([[ "${main_definition}" != *"collect_docker_logs"* ]] &&
[[ "${main_definition}" != *"COLLECT DOCKER LOGS"* ]]) ||
[[ "${main_definition}" != *"deploy_cluster.sh"* ]]; then
return 0
fi

# shellcheck source=/dev/null
source "${timer_script}"
external_regression_stage_timer_enable_auto_hooks
}

github_utils__maybe_enable_external_stage_timer
129 changes: 129 additions & 0 deletions regression-test/pipeline/common/stage-timer.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
#!/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.

stage_timer__now() {
date +%s
}

stage_timer__format_seconds() {
local total_seconds="${1:-0}"
local hours=$((total_seconds / 3600))
local minutes=$(((total_seconds % 3600) / 60))
local seconds=$((total_seconds % 60))
printf '%02d:%02d:%02d' "${hours}" "${minutes}" "${seconds}"
}

stage_timer__extract_exit_trap() {
local trap_desc
trap_desc="$(trap -p EXIT)"
if [[ "${trap_desc}" =~ ^trap\ --\ \'(.*)\'\ EXIT$ ]]; then
printf '%s' "${BASH_REMATCH[1]}"
fi
}

stage_timer__record_current_stage() {
local end_at="$1"
if [[ -z "${STAGE_TIMER_CURRENT_STAGE:-}" ]]; then
return 0
fi
STAGE_TIMER_STAGE_NAMES+=("${STAGE_TIMER_CURRENT_STAGE}")
STAGE_TIMER_STAGE_SECONDS+=("$((end_at - STAGE_TIMER_CURRENT_STAGE_STARTED_AT))")
STAGE_TIMER_CURRENT_STAGE=''
STAGE_TIMER_CURRENT_STAGE_STARTED_AT=''
}

stage_timer__print_summary() {
local exit_code="$1"
local finished_at="$2"
local total_seconds="$((finished_at - STAGE_TIMER_STARTED_AT))"
local index

echo "========== ${STAGE_TIMER_PIPELINE_NAME} 阶段耗时汇总 =========="
for index in "${!STAGE_TIMER_STAGE_NAMES[@]}"; do
printf '[stage-timer] %s: %s (%ss)\n' \
"${STAGE_TIMER_STAGE_NAMES[$index]}" \
"$(stage_timer__format_seconds "${STAGE_TIMER_STAGE_SECONDS[$index]}")" \
"${STAGE_TIMER_STAGE_SECONDS[$index]}"
done
printf '[stage-timer] 总耗时: %s (%ss)\n' \
"$(stage_timer__format_seconds "${total_seconds}")" \
"${total_seconds}"
printf '[stage-timer] 退出码: %s\n' "${exit_code}"
echo "=================================================="
}

stage_timer_finish() {
local exit_code="${1:-$?}"
local finished_at
if [[ "${STAGE_TIMER_INITIALIZED:-false}" != "true" ]]; then
return 0
fi
if [[ "${STAGE_TIMER_SUMMARY_PRINTED:-false}" == "true" ]]; then
return 0
fi
finished_at="$(stage_timer__now)"
stage_timer__record_current_stage "${finished_at}"
stage_timer__print_summary "${exit_code}" "${finished_at}"
STAGE_TIMER_SUMMARY_PRINTED=true
}

stage_timer__handle_exit() {
local exit_code="${1:-0}"
stage_timer_finish "${exit_code}"
if [[ -n "${STAGE_TIMER_PREVIOUS_EXIT_TRAP:-}" ]] &&
[[ "${STAGE_TIMER_PREVIOUS_EXIT_TRAP}" != *"stage_timer__handle_exit"* ]]; then
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the EXIT trap chaining, the previous EXIT trap will observe an incorrect $? value because stage_timer_finish runs before evaling the captured trap. If the previous trap uses $? (common for cleanup/reporting), it will see the status of stage_timer_finish (likely 0) instead of the script’s real exit code. Consider restoring $? to exit_code (e.g., via a no-op subshell (exit "$exit_code") before eval) or otherwise ensuring the previous trap runs with the original exit status.

Suggested change
[[ "${STAGE_TIMER_PREVIOUS_EXIT_TRAP}" != *"stage_timer__handle_exit"* ]]; then
[[ "${STAGE_TIMER_PREVIOUS_EXIT_TRAP}" != *"stage_timer__handle_exit"* ]]; then
# Restore the original exit status so that any `$?` in the previous
# EXIT trap observes the script's real exit code instead of the
# status of stage_timer_finish.
( exit "${exit_code}" )

Copilot uses AI. Check for mistakes.
eval "${STAGE_TIMER_PREVIOUS_EXIT_TRAP}"
fi
}

stage_timer_init() {
local pipeline_name="${1:-}"
if [[ -z "${pipeline_name}" ]]; then
echo "ERROR: stage_timer_init need pipeline name"
return 1
fi

STAGE_TIMER_PIPELINE_NAME="${pipeline_name}"
STAGE_TIMER_STARTED_AT="$(stage_timer__now)"
STAGE_TIMER_CURRENT_STAGE=''
STAGE_TIMER_CURRENT_STAGE_STARTED_AT=''
STAGE_TIMER_SUMMARY_PRINTED=false
STAGE_TIMER_INITIALIZED=true
STAGE_TIMER_STAGE_NAMES=()
STAGE_TIMER_STAGE_SECONDS=()
STAGE_TIMER_PREVIOUS_EXIT_TRAP="$(stage_timer__extract_exit_trap)"
trap 'stage_timer__handle_exit "$?"' EXIT
}

stage_timer_enter() {
local stage_name="${1:-}"
local now
if [[ "${STAGE_TIMER_INITIALIZED:-false}" != "true" ]]; then
echo "ERROR: stage_timer_enter called before stage_timer_init"
return 1
fi
if [[ -z "${stage_name}" ]]; then
echo "ERROR: stage_timer_enter need stage name"
return 1
fi

now="$(stage_timer__now)"
stage_timer__record_current_stage "${now}"
STAGE_TIMER_CURRENT_STAGE="${stage_name}"
STAGE_TIMER_CURRENT_STAGE_STARTED_AT="${now}"
}
149 changes: 149 additions & 0 deletions regression-test/pipeline/common/test_stage_timer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
#!/usr/bin/env python
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test script uses tempfile.TemporaryDirectory() which requires Python 3, but the shebang is #!/usr/bin/env python (may resolve to Python 2 on some environments). Update the shebang to python3 (or remove it if the test is always invoked via an explicit interpreter) to avoid running under Python 2 and failing at import/runtime.

Suggested change
#!/usr/bin/env python
#!/usr/bin/env python3

Copilot uses AI. Check for mistakes.
# 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.

import os
import stat
import subprocess
import tempfile
import unittest


ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
HELPER = os.path.join(ROOT, "regression-test", "pipeline", "common", "stage-timer.sh")
GITHUB_HELPER = os.path.join(ROOT, "regression-test", "pipeline", "common", "github-utils.sh")


class StageTimerTest(unittest.TestCase):
def _run_raw(self, body):
with tempfile.NamedTemporaryFile("w", suffix=".sh", delete=False) as script:
script.write("#!/usr/bin/env bash\n")
script.write("set -euo pipefail\n")
script.write("export LC_ALL=C.UTF-8\n")
script.write(body)
script_path = script.name
try:
return subprocess.run(
["bash", script_path],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
universal_newlines=True,
check=False,
)
finally:
os.unlink(script_path)

def _run(self, body):
return self._run_raw('source "{}"\n{}'.format(HELPER, body))

def test_prints_stage_summary_on_success(self):
result = self._run(
"""
stage_timer_init "external regression"
stage_timer_enter "前置准备"
sleep 1
stage_timer_enter "启动 Doris"
stage_timer_enter "启动依赖"
stage_timer_enter "执行 Case"
stage_timer_enter "收尾归档"
"""
)
self.assertEqual(result.returncode, 0, result.stdout)
self.assertIn("external regression 阶段耗时汇总", result.stdout)
self.assertIn("前置准备", result.stdout)
self.assertIn("启动 Doris", result.stdout)
self.assertIn("启动依赖", result.stdout)
self.assertIn("执行 Case", result.stdout)
self.assertIn("收尾归档", result.stdout)
self.assertIn("总耗时", result.stdout)

def test_prints_stage_summary_on_failure(self):
result = self._run(
"""
stage_timer_init "external regression"
stage_timer_enter "前置准备"
false
"""
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("external regression 阶段耗时汇总", result.stdout)
self.assertIn("前置准备", result.stdout)
self.assertIn("退出码", result.stdout)

def test_external_pipeline_auto_hooks_print_summary(self):
with tempfile.TemporaryDirectory() as tmpdir:
for name in ("deploy_cluster.sh", "run-thirdparties-docker.sh", "run-regression-test.sh"):
script_path = os.path.join(tmpdir, name)
with open(script_path, "w") as script:
script.write("#!/usr/bin/env bash\n")
script.write("exit 0\n")
os.chmod(script_path, stat.S_IRWXU)

result = self._run_raw(
"""
export teamcity_build_checkoutDir="{root}"

main() {{
source "{github_helper}"
echo "PREPARE"
cd "{tmpdir}" && bash deploy_cluster.sh test_cluster
echo "START EXTERNAL DOCKER"
cd "{tmpdir}" && bash run-thirdparties-docker.sh --start
echo "RUN EXTERNAL CASE"
cd "{tmpdir}" && ./run-regression-test.sh --teamcity --clean --run
echo "COLLECT DOCKER LOGS"
}}

main
""".format(
root=ROOT,
github_helper=GITHUB_HELPER,
tmpdir=tmpdir,
)
)

self.assertEqual(result.returncode, 0, result.stdout)
self.assertIn("external regression 阶段耗时汇总", result.stdout)
self.assertIn("前置准备", result.stdout)
self.assertIn("启动 Doris", result.stdout)
self.assertIn("启动依赖", result.stdout)
self.assertIn("执行 Case", result.stdout)
self.assertIn("收尾归档", result.stdout)

def test_non_external_pipeline_does_not_enable_auto_hooks(self):
result = self._run_raw(
"""
export teamcity_build_checkoutDir="{root}"

main() {{
source "{github_helper}"
echo "regular pipeline"
}}

main
""".format(
root=ROOT,
github_helper=GITHUB_HELPER,
)
)

self.assertEqual(result.returncode, 0, result.stdout)
self.assertNotIn("external regression 阶段耗时汇总", result.stdout)


if __name__ == "__main__":
unittest.main()
Loading
Loading