This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/dev/pr-12628-ea3d6df4a0721ecf9f42811a9cf3e00d82651700 in repository https://gitbox.apache.org/repos/asf/seatunnel.git
commit ee90b50012d44d7b96ee47f07afe995a475a79d2 Author: SEZ <[email protected]> AuthorDate: Thu Oct 8 13:35:43 2026 +0000 [Improve][seatunnel-cli] Keep the root cause when reporting a job failure (#12628) --- seatunnel-cli/seatunnel_cli/cli.py | 44 +++++++++++- seatunnel-cli/tests/test_failure_report.py | 103 +++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 3 deletions(-) diff --git a/seatunnel-cli/seatunnel_cli/cli.py b/seatunnel-cli/seatunnel_cli/cli.py index fe77068fa2..83db23a9e8 100644 --- a/seatunnel-cli/seatunnel_cli/cli.py +++ b/seatunnel-cli/seatunnel_cli/cli.py @@ -39,6 +39,7 @@ from prompt_toolkit.completion import WordCompleter from prompt_toolkit.history import FileHistory from . import __version__, get_data_dir +from .diagnostics import ParsedError, parse_error from .llm_provider import create_provider, format_llm_error from .agents import Orchestrator @@ -123,6 +124,28 @@ def _restore_creds_from_placeholders(config: str, cred_map: dict[str, str]) -> s return config +_ERROR_EXCERPT_LIMIT = 3000 +_OMITTED_MARKER = "...(earlier frames omitted)...\n" + + +def _build_failure_report(error_text: str) -> tuple[ParsedError, str]: + """Parse a raw job failure and render the excerpt that is sent to the model. + + Two separate things happen here on purpose. The parse runs on the *whole* + text, because the structured fields are cheap and we want them even when + the trace is far too long to forward. The excerpt keeps the *end* of the + text, because a Java trace prints the outer wrapper first and the innermost + ``Caused by`` last -- so cutting the front discards the root cause, the one + field a repair prompt actually needs. `_run_local` already tail-truncates + its captured output for the same reason. + """ + parsed = parse_error(error_text) + text = error_text or "" + if len(text) <= _ERROR_EXCERPT_LIMIT: + return parsed, text + return parsed, _OMITTED_MARKER + text[-_ERROR_EXCERPT_LIMIT:] + + class SeaTunnelCLI: """Interactive CLI for SeaTunnel config generation.""" @@ -1411,7 +1434,9 @@ class SeaTunnelCLI: def _show_error_and_diagnose(self, error_text: str): """Diagnose error and directly patch the existing config, with conversation memory.""" from .memory import redact_credentials - truncated = error_text[:3000] + parsed, excerpt = _build_failure_report(error_text) + if parsed.code or parsed.exception: + self.console.print(f" Root cause: {parsed.headline()}", style="info") self.console.print(" Diagnosing and fixing config...", style="info") repair_system = ( @@ -1435,9 +1460,22 @@ class SeaTunnelCLI: safe_config, cred_map = _replace_creds_with_placeholders( self.last_config ) if self.last_config else ("", {}) - safe_error = redact_credentials(truncated) + safe_error = redact_credentials(excerpt) + # The parsed fields come from the whole trace, so they survive even when + # the excerpt below had to drop frames. Stating them separately also stops + # the model from having to re-derive the error code from the raw text. + # They are redacted as well: they are cut from that same trace, so a + # root_cause line can carry a JDBC URL with a password in it. "signature" + # is dropped because it is an internal dedupe key, not a diagnosis. + facts = "\n".join( + f"- {k}: {redact_credentials(str(v))}" + for k, v in parsed.as_dict().items() + if k != "signature" + ) repair_msg = ( - f"Job execution failed with this error:\n\n```\n{safe_error}\n```\n\n" + f"Job execution failed.\n\n" + f"Parsed failure:\n{facts}\n\n" + f"Raw error:\n\n```\n{safe_error}\n```\n\n" f"The config that failed:\n```hocon\n{safe_config}\n```\n\n" f"Fix this config. Only change what's needed to resolve the error." ) diff --git a/seatunnel-cli/tests/test_failure_report.py b/seatunnel-cli/tests/test_failure_report.py new file mode 100644 index 0000000000..6f2d566aaa --- /dev/null +++ b/seatunnel-cli/tests/test_failure_report.py @@ -0,0 +1,103 @@ +# +# 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. +# + +"""Tests for the failure excerpt handed to the config-repair prompt.""" + +from seatunnel_cli.cli import ( + _ERROR_EXCERPT_LIMIT, + _OMITTED_MARKER, + _build_failure_report, +) + + +def _long_trace() -> str: + """A trace longer than the excerpt limit whose root cause is at the very end. + + This is the realistic shape: Zeta wraps the connector failure, so the + generic wrapper is printed first and the specific `Caused by` last. Long + traces are produced by deep frame chains, not by a long first line. + """ + head = ( + "org.apache.seatunnel.core.starter.exception.CommandExecuteException: " + "SeaTunnel job executed failed\n" + ) + filler = "".join( + "\tat org.apache.seatunnel.engine.server.task.SeaTunnelTask." + f"stateProcess(SeaTunnelTask.java:{line})\n" + for line in range(100, 140) + ) + while len(head + filler) <= _ERROR_EXCERPT_LIMIT: + filler += filler + tail = ( + "Caused by: org.apache.seatunnel.common.exception.SeaTunnelRuntimeException: " + "ErrorCode:[JDBC-05], ErrorDescription:[Connect to database failed]\n" + "Caused by: java.sql.SQLException: Access denied for user 'bench'\n" + ) + return head + filler + tail + + +def test_root_cause_is_parsed_from_the_end_of_an_oversized_trace(): + # The whole point of parsing before truncating: the error code lives past + # the excerpt limit, so a parser fed the truncated text would see nothing. + trace = _long_trace() + assert len(trace) > _ERROR_EXCERPT_LIMIT + + parsed, _ = _build_failure_report(trace) + + assert parsed.code == "JDBC-05" + assert parsed.description == "Connect to database failed" + assert parsed.component == "JDBC" + + +def test_excerpt_keeps_the_tail_rather_than_the_head(): + trace = _long_trace() + + _, excerpt = _build_failure_report(trace) + + assert excerpt.startswith(_OMITTED_MARKER) + assert len(excerpt) == len(_OMITTED_MARKER) + _ERROR_EXCERPT_LIMIT + # The root cause must survive; the outer wrapper is what may be dropped. + assert "Access denied for user" in excerpt + assert "ErrorCode:[JDBC-05]" in excerpt + assert "CommandExecuteException" not in excerpt + + +def test_short_trace_is_forwarded_unchanged_and_unmarked(): + trace = "java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver\n" + + parsed, excerpt = _build_failure_report(trace) + + assert excerpt == trace + assert _OMITTED_MARKER not in excerpt + assert parsed.exception == "java.lang.ClassNotFoundException" + + +def test_a_trace_exactly_at_the_limit_is_not_marked_as_cut(): + trace = "x" * _ERROR_EXCERPT_LIMIT + + _, excerpt = _build_failure_report(trace) + + assert excerpt == trace + + +def test_empty_and_non_string_input_do_not_raise(): + # This runs while the CLI is already reporting a failure; raising here would + # replace the user's real error with ours. + for value in ("", None): + parsed, excerpt = _build_failure_report(value) + assert excerpt == "" + assert parsed.signature == "empty"
