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-12688-a9db0e65428bd95dfd00ee21484f10dc6908db27 in repository https://gitbox.apache.org/repos/asf/seatunnel.git
commit 714aeed0c486818d91e83d73b4d8e5dfbfbacc12 Author: SEZ <[email protected]> AuthorDate: Wed Oct 14 12:52:26 2026 +0000 [Feature][seatunnel-cli] Diagnose a gradual slowdown from checkpoint history (#12688) --- seatunnel-cli/README.md | 85 +++ seatunnel-cli/README.zh-CN.md | 74 +++ seatunnel-cli/seatunnel_cli/cli.py | 217 +++++++ .../seatunnel_cli/diagnostics/checkpoints.py | 468 ++++++++++++++ seatunnel-cli/seatunnel_cli/diagnostics/jobs.py | 525 ++++++++++++++++ seatunnel-cli/tests/test_diagnose_checkpoints.py | 576 +++++++++++++++++ seatunnel-cli/tests/test_diagnose_cli.py | 575 +++++++++++++++++ seatunnel-cli/tests/test_diagnose_job.py | 683 +++++++++++++++++++++ 8 files changed, 3203 insertions(+) diff --git a/seatunnel-cli/README.md b/seatunnel-cli/README.md index 58f6888e0c..71d1d07e29 100644 --- a/seatunnel-cli/README.md +++ b/seatunnel-cli/README.md @@ -276,6 +276,90 @@ seatunnel "从 Kafka 读取订单数据写入 ClickHouse" -o my_job.conf seatunnel "Read CSV files and write to Elasticsearch" --provider openai --model gpt-4o ``` +### Diagnosing a Job + +`--diagnose` answers "why is this job not making progress?" from what the +engine already publishes. It is read-only -- a `GET /job-info/<id>` and a +`GET /jobs/checkpoints/<id>`, never a submit or a stop -- and uses no LLM at +all, so it works with no API key and no provider configured: + +```bash +seatunnel --diagnose 852362670771666945 + +# Another host, or a cluster whose enable-dynamic-port moved the listener +SEATUNNEL_API_BASE=http://zeta-master:8080 seatunnel --diagnose 852362670771666945 +``` + +`/job-info` is part of the v2 REST API, which Zeta serves from its Jetty HTTP +port -- `8080` in the packaged `config/seatunnel.yaml`. Point +`SEATUNNEL_API_BASE` there if your cluster is elsewhere or uses another port. + +``` +Job 852362670771666945 mysql-to-doris — RUNNING + [warning] Pipeline 1 has restarted 4 times and its current attempt is only 20s old. + It is failing and being restored repeatedly, so the job looks alive while + making no progress. (restoreCount=4/10 pipelineStatus=RUNNING running for 20s + since the last restore) +``` + +What it reports: the parsed error code and root cause of a failed job, a +pipeline restarting in a loop (and whether it has used up its restore budget), +a job that has sat in a pre-running state too long to be normal, and a running +job whose source reads nothing or whose sink writes nothing. Exit status is +non-zero only when the job itself is `FAILED`, so a stuck-but-live job does not +look like a command failure. + +It also reports **why a job that used to be fast has got slower**, which the +job state alone cannot explain: a job that is lagging is still `RUNNING` with +both row counters moving. Checkpoint history supplies the missing comparison: + +``` +Job 852362670771666945 mysql-cdc-to-doris — RUNNING + [warning] Pipeline 1 checkpoints are getting slower: recently 48.0s against 4.0s + earlier in the retained history. (median durationMillis 4000 -> 48000 over 16 checkpoints) + [warning] Pipeline 1 now spends almost all its time checkpointing: a checkpoint takes + 48.0s and one is triggered every 1m00s. (recent median duration 48000ms vs interval 60000ms) + [info] Pipeline 1 checkpoint state is growing: recently 120.0MiB against 12.0MiB earlier. +``` + +A stalled checkpoint is also named down to the subtasks holding up the barrier +(`3 of 8 subtasks have acknowledged it`), which points at one stage rather than +at the job as a whole. + +The engine keeps the last 32 checkpoints per pipeline, so the trend covers that +window rather than the whole run. The "spends its time checkpointing" rule +needs no baseline and so still fires on a job that degraded earlier and has +been slow since. If the cluster predates the checkpoint endpoint, or its +monitor service is off, the rest of the diagnosis is printed unchanged, with +one line noting that this part of the analysis did not run. + +Every rule is deliberately limited to what one response can prove, because a +wrong hint costs more than a missing one: + +- `restoreCount` counts restores since submission, so what it means depends on + the pipeline's current state. A pipeline that is `FINISHED` or `CANCELED` is + over and says nothing; a `FAILED` one is reported only if it spent its whole + restore budget, in the past tense; one that is shutting down reports its + restores as history. A loop is only claimed when the pipeline is between + attempts, or has been running for less than 10 minutes since its last + restore — so a loop whose attempts each survive longer than that is reported + as history rather than as a live fault. +- the row-count rules wait until the job has been RUNNING for two minutes, and + for a `job.mode = STREAMING` job an idle source is reported as a note rather + than a warning -- a quiet topic and a misconfigured one read the same here. +- `DOING_SAVEPOINT` is reported as a duration, not as a hung task: a large + state legitimately takes minutes to write. +- a checkpoint failure is only a warning while the **latest** failure is newer + than the latest completion. `counts.failed` is cumulative, so one expired + checkpoint would otherwise keep it above zero forever. +- the stalled-checkpoint rule fires after 2 minutes in progress, which is above + the packaged `checkpoint.timeout` of 60s. On default settings a hung + checkpoint expires first and is reported as a failure instead, so this rule + matters on clusters that have raised that timeout. +- durations are measured against the engine's own clock -- the job rules use + `diagnostics.generatedAt`, the checkpoint rules use `updatedAt` -- so a + client clock that disagrees with the cluster cannot invent a stuck job. + ### CLI Arguments ``` @@ -289,6 +373,7 @@ Options: --provider PROVIDER LLM provider: bedrock | bedrock-mantle | anthropic | openai | orcarouter --model MODEL Override primary model ID --fast-model MODEL Override fast model ID + --diagnose JOB_ID Explain a job's current state and exit (read-only, no LLM) --sync-catalog PATH Regenerate connector catalog from SeaTunnel source -V, --version Show version -h, --help Show help message diff --git a/seatunnel-cli/README.zh-CN.md b/seatunnel-cli/README.zh-CN.md index f57c82fc23..a5481d4767 100644 --- a/seatunnel-cli/README.zh-CN.md +++ b/seatunnel-cli/README.zh-CN.md @@ -244,6 +244,79 @@ seatunnel "从 Kafka 读取订单数据写入 ClickHouse" -o my_job.conf seatunnel "Read CSV files and write to Elasticsearch" --provider openai --model gpt-4o ``` +### 诊断作业 + +`--diagnose` 用引擎已经暴露的信息回答"这个作业为什么不往前走"。它是只读的—— +只发 `GET /job-info/<id>` 和 `GET /jobs/checkpoints/<id>`,不会提交也不会停止 +作业——并且完全不调用 LLM,因此在没有 API key、没有配置任何提供商的情况下也能用: + +```bash +seatunnel --diagnose 852362670771666945 + +# 集群在其他主机上,或 enable-dynamic-port 让监听端口发生了变化 +SEATUNNEL_API_BASE=http://zeta-master:8080 seatunnel --diagnose 852362670771666945 +``` + +`/job-info` 属于 v2 REST API,由 Zeta 的 Jetty HTTP 端口提供服务——打包配置 +`config/seatunnel.yaml` 中是 `8080`。集群在别处或端口不同时,请把 +`SEATUNNEL_API_BASE` 指向该地址。 + +``` +Job 852362670771666945 mysql-to-doris — RUNNING + [warning] Pipeline 1 has restarted 4 times and its current attempt is only 20s old. + It is failing and being restored repeatedly, so the job looks alive while + making no progress. (restoreCount=4/10 pipelineStatus=RUNNING running for 20s + since the last restore) +``` + +它会报告:失败作业解析出的错误码与根因、反复重启的 pipeline(以及是否已用尽 +重启次数)、在运行前状态停留过久的作业、以及源端读不到数据或目标端写不出数据的 +运行中作业。只有作业本身处于 `FAILED` 时退出码才非零——卡住但仍存活的作业不会被 +当成命令执行失败。 + +它还会回答**原本很快的作业为什么变慢了**——这是作业状态本身无法解释的:出现延迟的 +作业仍然是 `RUNNING`,两侧的行数也都在涨。checkpoint 历史补上了缺失的那个对比: + +``` +Job 852362670771666945 mysql-cdc-to-doris — RUNNING + [warning] Pipeline 1 checkpoints are getting slower: recently 48.0s against 4.0s + earlier in the retained history. (median durationMillis 4000 -> 48000 over 16 checkpoints) + [warning] Pipeline 1 now spends almost all its time checkpointing: a checkpoint takes + 48.0s and one is triggered every 1m00s. (recent median duration 48000ms vs interval 60000ms) + [info] Pipeline 1 checkpoint state is growing: recently 120.0MiB against 12.0MiB earlier. +``` + +卡住的 checkpoint 还会定位到是哪些 subtask 在挡住 barrier(`3 of 8 subtasks have +acknowledged it`),指向具体的某一环而不是整个作业。 + +引擎每个 pipeline 只保留最近 32 个 checkpoint,因此趋势只覆盖这个窗口,而非整个 +运行周期。「几乎全部时间都在做 checkpoint」这条规则不需要基线,所以对于更早就已经 +退化、之后一直慢的作业同样有效。如果集群版本没有该 checkpoint 端点,或其监控服务 +未开启,诊断的其余部分照常输出,并会额外打印一行说明这部分分析没有执行。 + +每条规则都刻意只说单次响应能证明的事情,因为错误的提示比没有提示代价更大: + +- `restoreCount` 统计的是自提交以来的重启次数,所以它的含义完全取决于 pipeline + 当前的状态。`FINISHED` / `CANCELED` 的 pipeline 已经结束,不会输出任何内容; + `FAILED` 的只在重启预算用尽时以过去时报告;正在关停的则把重启次数作为历史 + 记录输出。只有当 pipeline 处于两次尝试之间、或距上次重启运行不足 10 分钟时, + 才会判定为重启循环——因此每次尝试都能存活超过 10 分钟的循环会被当作历史 + 记录,而不是当前故障。 +- 行数规则要等作业进入 RUNNING 满两分钟后才生效;对 `job.mode = STREAMING` + 的作业,源端空闲只作为提示而非告警——安静的 topic 和配错的 topic 在这里 + 读数完全相同。 +- `DOING_SAVEPOINT` 只报告持续时长,不会说成任务挂死:状态大时写 savepoint + 本来就要几分钟。 +- checkpoint 失败只有在**最近一次失败比最近一次成功更新**时才告警。 + `counts.failed` 是自提交以来的累计值,一次早已过期的 checkpoint 会让它永远 + 大于零。 +- checkpoint 卡住的判定阈值是进行中超过 2 分钟,高于打包配置里的 + `checkpoint.timeout`(60 秒)。默认配置下挂死的 checkpoint 会先超时、以失败 + 的形式报出,因此这条规则主要服务于调高了该超时的集群。 +- 时长以引擎自己的时钟为基准计算(作业规则用 `diagnostics.generatedAt`, + checkpoint 规则用 `updatedAt`),客户端时钟与集群不一致时不会凭空造出一个 + "卡住"的作业。 + ### CLI 参数 ``` @@ -257,6 +330,7 @@ seatunnel [request] [options] --provider PROVIDER LLM 提供商:bedrock | bedrock-mantle | anthropic | openai | orcarouter --model MODEL 覆盖主模型 ID --fast-model MODEL 覆盖快速模型 ID + --diagnose JOB_ID 诊断作业当前状态后退出(只读,不调用 LLM) --sync-catalog PATH 从 SeaTunnel 源码重新生成连接器目录 -V, --version 显示版本 -h, --help 显示帮助信息 diff --git a/seatunnel-cli/seatunnel_cli/cli.py b/seatunnel-cli/seatunnel_cli/cli.py index b7e4512b0e..af1ec00cb9 100644 --- a/seatunnel-cli/seatunnel_cli/cli.py +++ b/seatunnel-cli/seatunnel_cli/cli.py @@ -28,6 +28,7 @@ from pathlib import Path from rich.console import Console from rich.live import Live from rich.markdown import Markdown +from rich.markup import escape from rich.panel import Panel from rich.spinner import Spinner from rich.syntax import Syntax @@ -43,6 +44,8 @@ from .diagnostics import ParsedError, parse_error from .llm_provider import create_provider, format_llm_error from .agents import Orchestrator +logger = logging.getLogger(__name__) + # ─── Theme ─── @@ -1573,6 +1576,206 @@ class SeaTunnelCLI: self.console.print(f" [error]Failed to execute: {e}[/error]") +_SEVERITY_STYLE = {"error": "error", "warning": "warning", "info": "info"} + +# The engine parses a job id as a decimal long, so anything else can only be a +# typo or a mangled copy-paste. Catching it here turns "1/2" -- which would +# otherwise be sent as the URL path `/job-info/1/2` and come back as a 404 -- +# into a message that names the actual problem. +_JOB_ID_RE = re.compile(r"-?\d+$") + +# An engine error body is bounded only by rest.MAX_RESPONSE_BYTES (8 MiB). +# That is the right limit for a response being parsed; it is the wrong amount +# of text to print into someone's terminal, so the printed form is clipped. +_ERROR_BODY_MAX_CHARS = 2000 + + +def _clip(text: str, limit: int = _ERROR_BODY_MAX_CHARS) -> str: + """Shorten engine-supplied text for display, saying so when it is cut.""" + return text if len(text) <= limit else f"{text[:limit]}... [{len(text)} chars total]" + + +def run_diagnose(job_id: str, console: Console) -> int: + """Explain one job's current state from ``/job-info``. Returns an exit code. + + Deliberately model-free. Every line printed here is derived from a field + the engine publishes, so this works with no API key, no provider configured + and no network beyond the cluster -- which is exactly the situation someone + is in when a job is stuck in production and they want an answer now. + + It is also read-only: two GETs, no submit, no stop, no savepoint. That is + what makes it safe to point at a live production job. + """ + from .connectors import _ENGINE_API_BASE + from .diagnostics.checkpoints import diagnose_checkpoints + from .diagnostics.jobs import as_int, diagnose_job, is_non_terminal, sort_findings + from .memory import redact_credentials + + if not _JOB_ID_RE.match(job_id): + console.print( + f"[error]{escape(job_id)} is not a job id.[/error] A SeaTunnel job id is a " + f"decimal number, as printed on submit and listed by /running-jobs." + ) + return 1 + + url = f"{_ENGINE_API_BASE}/job-info/{job_id}" + try: + job_info = rest.request_json(url, timeout=10) + except rest.RestError as e: + # A 404 here is the single most likely mistake (wrong id, a job old + # enough to have been evicted from the finished-job history, or the + # base URL pointing at a port that does not serve the v2 REST API), so + # it is worth separating from a cluster that is not answering at all. + if e.status == 404: + console.print( + f"[error]No job {escape(job_id)} at {escape(_ENGINE_API_BASE)}.[/error] It may " + f"have been evicted from the finished-job history, or the id may be wrong. " + f"If the cluster is elsewhere, set SEATUNNEL_API_BASE to its REST address " + f"(the Jetty HTTP port, 8080 in the packaged config -- not the member port 5801)." + ) + else: + # Redacted and capped for the same reasons the finding lines are: + # the engine body can quote a JDBC URL with a password, and + # rest.py only bounds it at MAX_RESPONSE_BYTES (8 MiB), which is + # far more than anyone wants pasted into a terminal. + console.print( + f"[error]Could not read job {escape(job_id)} ({e.status}):[/error] " + f"{escape(_clip(redact_credentials(str(e.body))))}" + ) + return 1 + except Exception as e: + console.print( + f"[error]Could not reach the engine at {escape(_ENGINE_API_BASE)}:[/error] " + f"{escape(str(e))}\n" + f"Set SEATUNNEL_API_BASE if the REST API is on another host or port." + ) + return 1 + + # The engine does not 404 for an id it does not know: JobInfoService falls + # through to `{"jobId": "<id>"}` with status 200 when the job is in + # neither the running-job map nor the finished-job state. Without this + # check that response reaches the rules as a job with no status, nothing + # matches, and the output reads "Nothing to report" -- i.e. "your job is + # fine" about a job that does not exist. A non-object body (a bare `null` + # or a list from a proxy) is the same situation and is caught here too, + # before anything calls .get() on it. + status = job_info.get("jobStatus") if isinstance(job_info, dict) else None + if not isinstance(status, str) or not status.strip(): + console.print( + f"[error]No job {escape(job_id)} on the cluster at " + f"{escape(_ENGINE_API_BASE)}.[/error] The engine returned no status for it, " + f"which means the id is unknown there: either it is wrong, or the job has " + f"been evicted from the finished-job history." + ) + return 1 + status = status.strip() + + name = job_info.get("jobName") or "(unnamed)" + # escape(): the job name is chosen by whoever submitted the job, so a name + # like "[prod] sync" would otherwise be read as a style tag and vanish. + console.print( + f"Job [bold]{escape(job_id)}[/bold] {escape(str(name))} " + f"— [bold]{escape(status)}[/bold]" + ) + + # The `diagnostics` block carries the restore counters and state + # timestamps, so the crash-loop and stuck-state rules are built entirely + # on it. When it is missing those rules have no input, and saying nothing + # would present "no data" as "no problem". + # + # Only while the job can still move, though: the engine builds the block + # for a job the master coordinates, so for a finished or cancelled job its + # absence is expected and the notice would be noise. + diagnostics_present = isinstance(job_info.get("diagnostics"), dict) + live_job = is_non_terminal(status) + # `generatedAt` is stamped by the master when it builds the response, so + # it is a usable "now" for both rule sets. None means fall back to the + # local clock inside each. + engine_now_ms = None + if diagnostics_present: + generated_at = as_int(job_info["diagnostics"].get("generatedAt")) + engine_now_ms = generated_at if generated_at and generated_at > 0 else None + if live_job and not diagnostics_present: + console.print( + " Restart counts and state ages were not available in this response, " + "so the crash-loop and stuck-state checks did not run (the master may " + "not be serving diagnostics for this job).", + style="info", + ) + + findings = diagnose_job(job_info) + + # Checkpoint history answers "why did it get slower", which job-info alone + # cannot. It is fetched best-effort on purpose: an older engine has no + # such endpoint and a cluster with the monitor service off returns nothing + # useful, and in neither case should a working diagnosis turn into a + # failure. Whatever job-info gave us is still worth printing. + try: + checkpoints = rest.request_json( + f"{_ENGINE_API_BASE}/jobs/checkpoints/{job_id}", timeout=10 + ) + except Exception as e: + checkpoints = None + logger.debug("Checkpoint overview for job %s unavailable: %s", job_id, e) + + # A 200 is not the same as data. When the monitor service is off, or has + # nothing recorded for the job yet, CheckpointMonitorRestService answers + # 200 with only {"jobId": ...} and no `pipelines` -- no exception to catch. + # Both that and a failed request leave the slowdown rules with no input, + # and staying silent would let the output end on "Nothing to report", + # which reads as "the job is fine". + checkpoints_usable = isinstance(checkpoints, dict) and isinstance( + checkpoints.get("pipelines"), list + ) + if live_job and not checkpoints_usable: + console.print( + " No checkpoint history for this job, so the slowdown checks did not run " + "(the cluster may predate the endpoint, its monitor service may be off, or " + "the job may not have checkpointed yet).", + style="info", + ) + if checkpoints_usable: + # The engine's own clock, from the /job-info response fetched a moment + # ago: diagnostics.generatedAt is stamped at response time, so the + # checkpoint ages are computed in the same clock as the timestamps + # they are subtracted from. + findings = sort_findings( + findings + diagnose_checkpoints(checkpoints, now_ms=engine_now_ms) + ) + + if not findings: + # Only claim a clean bill of health when the rules actually had their + # input; otherwise the notices above are the whole story. + inputs_complete = not live_job or (diagnostics_present and checkpoints_usable) + if inputs_complete: + console.print(" Nothing to report: no rule matched this job's state.", style="info") + return 0 + + for finding in findings: + # The error message can carry a JDBC URL with a password, and a + # finding quotes it, so redact on the way out even though nothing is + # being sent anywhere -- terminal output gets pasted into issues. + # + # markup=False because the finding text is data, not markup. Three + # reasons it matters: `render()` prefixes "[error]", and "error" is a + # style name in THEME, so rich would consume the prefix as a style tag + # and the severity would reach the user by colour alone -- invisible in + # a pipe, in CI, or under NO_COLOR. An engine message containing + # "ErrorDescription:[master is down]" would lose the bracketed part. + # And a fragment that looks like a closing tag, such as a path in + # "[/data/x.csv]", raises MarkupError -- crashing the one command + # someone runs when a job is already broken. + console.print( + f" {redact_credentials(finding.render())}", + style=_SEVERITY_STYLE.get(finding.severity, "info"), + markup=False, + ) + # Only a terminal failure is an error for the shell. A stuck or + # crash-looping job is a live job, and reporting it as a command failure + # would break `seatunnel --diagnose x && ...` for a job that is merely slow. + return 1 if status == "FAILED" else 0 + + def main(): """Entry point for seatunnel CLI.""" parser = argparse.ArgumentParser( @@ -1611,6 +1814,13 @@ def main(): action="store_true", help="Interactive first-time setup: choose LLM provider and save config", ) + parser.add_argument( + "--diagnose", + metavar="JOB_ID", + help="Explain the current state of a job on the cluster and exit. " + "Read-only and model-free: needs no API key, only a reachable " + "engine REST API (see SEATUNNEL_API_BASE).", + ) parser.add_argument( "--export-metadata", nargs="?", @@ -1629,6 +1839,13 @@ def main(): SeaTunnelCLI.run_init(console) sys.exit(0) + # --diagnose: read-only cluster query. Handled before any provider setup + # below, because it must work for a user who has no LLM configured at all. + if args.diagnose: + console = Console(theme=THEME) + _install_secret_log_filter() + sys.exit(run_diagnose(args.diagnose, console)) + # --export-metadata: run Java exporter, no LLM needed if args.export_metadata: from .connectors import export_runtime_metadata diff --git a/seatunnel-cli/seatunnel_cli/diagnostics/checkpoints.py b/seatunnel-cli/seatunnel_cli/diagnostics/checkpoints.py new file mode 100644 index 0000000000..45297adb3b --- /dev/null +++ b/seatunnel-cli/seatunnel_cli/diagnostics/checkpoints.py @@ -0,0 +1,468 @@ +# +# 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. +# + +"""Turn one ``/jobs/checkpoints/{jobId}`` response into findings. + +The rules in ``jobs.py`` all compare against zero or against a fixed +threshold, which answers "did it break" and "is it stuck" but not the question +people actually ask about a long-running CDC job: it was fine at first and now +it lags. The counters keep moving there, just more slowly, so nothing those +rules look at ever trips. + +This endpoint closes that gap because it is the one place the engine publishes +a *series* rather than a current value: up to 32 history entries per pipeline, +each with how long that checkpoint took and how big its state was. Comparing a +job against its own recent past then becomes arithmetic. + +Two independent signals are worth separating, because they fail to answer +different questions: + +* a **trend** -- recent checkpoints are slower than older ones. Needs a + baseline, so it only sees degradation still visible inside the retained + window (32 checkpoints; at a 10s interval that is roughly five minutes, not + three days). +* a **ratio** -- checkpoints take about as long as the gap between them, so the + job is checkpoint-bound right now. Needs no baseline at all, which is what + makes it useful for the job that got slower yesterday and then stayed slow. + +Pure functions over dicts: no I/O, no network, no model calls. Unknown or +missing input yields fewer findings, never a wrong one. +""" + +from __future__ import annotations + +import time + +from seatunnel_cli.diagnostics.jobs import Finding, as_int, sort_findings + +# A checkpoint in progress for longer than this is worth mentioning. The +# endpoint does not publish the configured interval, so there is no way to +# scale this to the job; the value is deliberately well above any healthy +# checkpoint so that a slow-but-working job is not reported as stalled. +# Note how this sits against the engine's own timeout: the packaged +# config/seatunnel.yaml sets `checkpoint.timeout: 60000`, so on default +# settings a hung checkpoint expires after 60s and surfaces through +# `checkpoint_failing` instead. This rule therefore earns its keep on +# clusters that have raised `checkpoint.timeout` above 120s, where a barrier +# can hang for minutes without the engine calling it failed. +STALLED_CHECKPOINT_SECONDS = 120 + +# "Triggered but never completed" is the normal reading for the first interval +# of every healthy job, so it takes a second round before it means anything. +MIN_TRIGGERS_NEVER_COMPLETED = 2 + +# Halving the history to compare medians needs enough samples that one slow +# outlier cannot carry the result. Six is the smallest split where each side +# has a real median rather than a single value or a pair. +MIN_TREND_SAMPLES = 6 + +# Doubling is a large enough step that it is not measurement noise, and it is +# the point where a user would describe the job as having got slower. +GROWTH_FACTOR = 2.0 + +# Below this, a doubling is arithmetically true and operationally irrelevant: +# 8ms to 20ms is not why a CDC pipeline is lagging, and reporting it would +# train people to ignore the rule. +DURATION_FLOOR_MS = 5_000 + +# Same reasoning for state: a few KiB doubling says nothing. +STATE_FLOOR_BYTES = 1 << 20 + +# At this fraction of the trigger interval the job is spending most of its +# time checkpointing, so the next checkpoint starts as the previous one ends +# and there is little room left for actual throughput. +INTERVAL_SATURATION = 0.8 + +_COMPLETED = "COMPLETED" + + +def _ms(millis: float) -> str: + """Format a millisecond duration the way an operator would say it.""" + millis = int(millis) + if millis < 1000: + return f"{millis}ms" + seconds = millis / 1000.0 + if seconds < 60: + return f"{seconds:.1f}s" + seconds = int(seconds) + if seconds < 3600: + return f"{seconds // 60}m{seconds % 60:02d}s" + return f"{seconds // 3600}h{(seconds % 3600) // 60:02d}m" + + +def _bytes(count: float) -> str: + count = float(count) + if count < 1024: + return f"{count:.0f}B" + for unit in ("KiB", "MiB"): + count /= 1024 + if count < 1024: + return f"{count:.1f}{unit}" + return f"{count / 1024:.1f}GiB" + + +def _median(values: list[float]) -> float: + ordered = sorted(values) + middle = len(ordered) // 2 + if len(ordered) % 2: + return float(ordered[middle]) + return (ordered[middle - 1] + ordered[middle]) / 2.0 + + +def _checkpoints(pipeline: dict) -> list[dict]: + """The ``checkpoint`` objects of one pipeline's history, newest first. + + Order matters and is not obvious: the engine prepends each new entry + (``PipelineCheckpointOverview.addHistory`` uses ``addFirst``) and the + history endpoint sorts by ``triggerTimestamp`` descending. A trend read in + array order without accounting for that reports the opposite conclusion, + so it is stated once here and relied on by every caller below. The test + suite pins it from the other direction too, with a history that is getting + faster rather than slower. + """ + history = pipeline.get("history") + if not isinstance(history, list): + return [] + out = [] + for entry in history: + if not isinstance(entry, dict): + continue + checkpoint = entry.get("checkpoint") + if isinstance(checkpoint, dict): + out.append(checkpoint) + return out + + +def _completed_only(checkpoints: list[dict]) -> list[dict]: + """Drop everything that is not a completed checkpoint. + + Failed and canceled checkpoints are in the same history array, and both + carry misleading values rather than absent ones: ``durationMillis`` and + ``completedTimestamp`` are omitted from the JSON when null, while + ``stateSize`` is a primitive ``long`` and so arrives as a truthful-looking + ``0``. Left in, they would drag a duration trend toward nothing and make a + growing state look like a shrinking one. + """ + return [c for c in checkpoints if c.get("status") == _COMPLETED] + + +def _trend(values: list[float]) -> tuple[float, float] | None: + """Medians of (recent half, older half), or None without enough samples. + + ``values`` is newest-first, so the recent half is the front of the list. + """ + if len(values) < MIN_TREND_SAMPLES: + return None + half = len(values) // 2 + return _median(values[:half]), _median(values[half:]) + + +def _recent_median(values: list[float]) -> float: + """Median of the recent half, or of everything when samples are few. + + Taking the median across the whole window would answer "what was this job + like on average recently", which is not the question. A job that was + healthy for most of the retained history and is checkpoint-bound now gets + its current state diluted by its own healthy past, and the rule stays + silent exactly when it is needed. + """ + if len(values) >= MIN_TREND_SAMPLES: + return _median(values[: len(values) // 2]) + return _median(values) + + +def _duration_findings(pipeline_id, completed: list[dict]) -> list[Finding]: + durations = [d for d in (as_int(c.get("durationMillis")) for c in completed) if d is not None] + trend = _trend([float(d) for d in durations]) + if trend is None: + return [] + recent, older = trend + if recent < DURATION_FLOOR_MS or older <= 0 or recent < older * GROWTH_FACTOR: + return [] + return [ + Finding( + "warning", + "checkpoint_duration_growing", + f"Pipeline {pipeline_id} checkpoints are getting slower: recently " + f"{_ms(recent)} against {_ms(older)} earlier in the retained history. " + f"A checkpoint that takes longer every round holds up the source, so " + f"throughput falls even though the job stays RUNNING.", + f"median durationMillis {int(older)} -> {int(recent)} " + f"over {len(durations)} checkpoints", + ) + ] + + +def _state_findings(pipeline_id, completed: list[dict]) -> list[Finding]: + sizes = [s for s in (as_int(c.get("stateSize")) for c in completed) if s is not None] + trend = _trend([float(s) for s in sizes]) + if trend is None: + return [] + recent, older = trend + if recent < STATE_FLOOR_BYTES or older <= 0 or recent < older * GROWTH_FACTOR: + return [] + return [ + Finding( + "info", + "checkpoint_state_growing", + f"Pipeline {pipeline_id} checkpoint state is growing: recently " + f"{_bytes(recent)} against {_bytes(older)} earlier. This is usually what " + f"is behind checkpoints taking longer.", + f"median stateSize {int(older)} -> {int(recent)} bytes", + ) + ] + + +def _interval_findings( + pipeline_id, checkpoints: list[dict], completed: list[dict] +) -> list[Finding]: + """Compare how long checkpoints take against how often they are triggered. + + Unlike the trend rules this needs no baseline, so it still fires on a job + that degraded before the retained window began and has been slow ever + since -- which is the common case by the time someone asks for help. + + Intervals come from every history entry, not just the completed ones: a + checkpoint that failed was still triggered on schedule, and dropping it + would overstate the gap between triggers. + """ + triggers = [t for t in (as_int(c.get("triggerTimestamp")) for c in checkpoints) if t] + durations = [d for d in (as_int(c.get("durationMillis")) for c in completed) if d is not None] + if len(triggers) < 3 or len(durations) < 3: + return [] + # Newest first, so the earlier element is the later timestamp. + gaps = [ + float(triggers[i] - triggers[i + 1]) + for i in range(len(triggers) - 1) + if triggers[i] > triggers[i + 1] + ] + if len(gaps) < 2: + return [] + interval = _recent_median(gaps) + duration = _recent_median([float(d) for d in durations]) + if interval <= 0 or duration < interval * INTERVAL_SATURATION: + return [] + return [ + Finding( + "warning", + "checkpoint_bound", + f"Pipeline {pipeline_id} now spends almost all its time checkpointing: " + f"a checkpoint takes {_ms(duration)} and one is triggered every " + f"{_ms(interval)}. Raise checkpoint.interval, or find why the snapshot " + f"is slow -- until then the job cannot go faster than its checkpoints.", + f"recent median duration {int(duration)}ms vs interval {int(interval)}ms", + ) + ] + + +def _triggered_at(pipeline: dict, key: str) -> int | None: + """``triggerTimestamp`` of ``latestCompleted`` / ``latestFailed``, if usable.""" + latest = pipeline.get(key) + if not isinstance(latest, dict): + return None + stamp = as_int(latest.get("triggerTimestamp")) + return stamp if stamp and stamp > 0 else None + + +def _oldest_in_progress_age_ms(pipeline: dict, now_ms: int) -> int | None: + in_progress = pipeline.get("inProgress") + if not isinstance(in_progress, list): + return None + ages = [ + now_ms - stamp + for stamp in ( + as_int(c.get("triggerTimestamp")) for c in in_progress if isinstance(c, dict) + ) + if stamp and stamp > 0 + ] + return max(ages) if ages else None + + +def _counts_findings(pipeline_id, pipeline: dict, now_ms: int) -> list[Finding]: + """Rules over ``counts``, which are lifetime totals rather than a state. + + Every value here counts since the job was submitted, so neither "failed" + nor "completed none" says anything about the present on its own. Both + rules therefore need a second piece of evidence -- the relative age of the + latest failure and completion, or how many rounds have been triggered -- + before they are allowed to describe the job as it is now. + """ + counts = pipeline.get("counts") + if not isinstance(counts, dict): + return [] + triggered = as_int(counts.get("triggered")) + completed = as_int(counts.get("completed")) + failed = as_int(counts.get("failed")) + + findings = [] + # `completed == 0`, not `not completed`: an absent or unparsable count is + # unknown, and reporting "completed none" from it would state as measured + # something the engine never said. + if triggered and completed == 0: + # A job that has just started legitimately reads triggered=1, + # completed=0 with that first checkpoint still in flight. Saying + # "nothing has been committed" there describes every healthy job in + # its first interval, so it needs either a second round or a first + # round that has already been running too long. + stalled_ms = _oldest_in_progress_age_ms(pipeline, now_ms) + rounds_enough = triggered >= MIN_TRIGGERS_NEVER_COMPLETED + first_round_overdue = stalled_ms is not None and ( + stalled_ms >= STALLED_CHECKPOINT_SECONDS * 1000 + ) + if rounds_enough or first_round_overdue: + evidence = f"triggered={triggered} completed=0" + if stalled_ms is not None: + evidence += f" oldest in progress for {_ms(stalled_ms)}" + findings.append( + Finding( + "warning", + "checkpoint_never_completed", + f"Pipeline {pipeline_id} has triggered {triggered} checkpoints and " + f"completed none. Nothing has been committed, so a restart replays " + f"from the beginning rather than resuming.", + evidence, + ) + ) + if failed: + reason = pipeline.get("latestFailed") + reason = reason.get("failureReason") if isinstance(reason, dict) else None + failed_at = _triggered_at(pipeline, "latestFailed") + completed_at = _triggered_at(pipeline, "latestCompleted") + evidence = f"failed={failed}" + if completed is not None: + evidence += f" completed={completed}" + + # The deciding question is not "has anything ever failed" but "did the + # last attempt fail". A pipeline with one expired checkpoint weeks ago + # and thousands of completions since keeps `failed` above zero for the + # rest of its life, and a warning claiming it makes "no durable + # progress" would be wrong for as long as it runs. + failing_now = failed_at is not None and (completed_at is None or failed_at > completed_at) + if failing_now: + detail = ( + f"Pipeline {pipeline_id} has {failed} failed checkpoints and its latest " + f"checkpoint failed rather than completed. Offsets stop advancing on " + f"every failure, so the job can appear to be reading while making no " + f"durable progress." + ) + if isinstance(reason, str) and reason.strip(): + detail += f" Last failure: {reason.strip()}" + findings.append(Finding("warning", "checkpoint_failing", detail, evidence)) + elif completed_at is not None: + detail = ( + f"Pipeline {pipeline_id} has {failed} failed checkpoints since it was " + f"submitted, but the most recent checkpoint completed, so it is " + f"committing progress now." + ) + if isinstance(reason, str) and reason.strip(): + detail += f" Last failure was: {reason.strip()}" + findings.append(Finding("info", "checkpoint_failed_before", detail, evidence)) + else: + # Counter present, no usable timestamps: report the count and the + # reason, but not a conclusion about now. + detail = ( + f"Pipeline {pipeline_id} has {failed} failed checkpoints since it was " + f"submitted. Whether the latest one failed is not visible in this " + f"response." + ) + if isinstance(reason, str) and reason.strip(): + detail += f" Last failure was: {reason.strip()}" + findings.append(Finding("info", "checkpoint_failed_before", detail, evidence)) + return findings + + +def _in_progress_findings(pipeline_id, pipeline: dict, now_ms: int) -> list[Finding]: + in_progress = pipeline.get("inProgress") + if not isinstance(in_progress, list): + return [] + findings = [] + for checkpoint in in_progress: + if not isinstance(checkpoint, dict): + continue + triggered_at = as_int(checkpoint.get("triggerTimestamp")) + if not triggered_at or triggered_at <= 0: + continue + age_ms = now_ms - triggered_at + if age_ms < STALLED_CHECKPOINT_SECONDS * 1000: + continue + acknowledged = as_int(checkpoint.get("acknowledged")) + total = as_int(checkpoint.get("total")) + checkpoint_id = checkpoint.get("checkpointId", "?") + detail = ( + f"Pipeline {pipeline_id} checkpoint {checkpoint_id} has been in progress " + f"for {_ms(age_ms)} without completing." + ) + evidence = f"triggerTimestamp={triggered_at}" + # Naming the subtasks that have not acknowledged turns "the job is slow" + # into "this stage is slow", which is the difference between a usable + # diagnosis and a restatement of the symptom. + if acknowledged is not None and total: + detail += ( + f" {acknowledged} of {total} subtasks have acknowledged it, so the " + f"remaining {total - acknowledged} are what the barrier is waiting on." + ) + evidence += f" acknowledged={acknowledged}/{total}" + findings.append(Finding("error", "checkpoint_stalled", detail, evidence)) + return findings + + +def diagnose_checkpoints(overview: dict, *, now_ms: int | None = None) -> list[Finding]: + """Apply every checkpoint rule to one overview response, worst finding first. + + The response carries only ``jobId`` when the monitor service is not + running or has nothing recorded for the job, which is a legitimate state + rather than an error: it yields no findings. + + ``now_ms`` should be the engine's own "now" where the caller has one -- + ``diagnostics.generatedAt`` from the ``/job-info`` response fetched moments + earlier, which the master stamps at response time. The local clock is the + fallback. + + Deliberately *not* the overview's own ``updatedAt``, which looks like the + right field and is not: the engine writes it inside + ``HazelcastCheckpointOverviewStateStore.updateOverview``, i.e. only when a + checkpoint event is recorded. For a checkpoint stuck waiting on its last + subtask the final write is that last acknowledgement, and nothing is + written afterwards, so ``updatedAt - triggerTimestamp`` freezes a few + seconds after the trigger and never reaches the stalled threshold. Using + it would make ``checkpoint_stalled`` unable to fire in the one case it + exists for. + """ + if not isinstance(overview, dict): + return [] + + if now_ms is None: + now_ms = int(time.time() * 1000) + + pipelines = overview.get("pipelines") + if not isinstance(pipelines, list): + return [] + + findings: list[Finding] = [] + for pipeline in pipelines: + if not isinstance(pipeline, dict): + continue + pipeline_id = pipeline.get("pipelineId", "?") + checkpoints = _checkpoints(pipeline) + completed = _completed_only(checkpoints) + + findings += _in_progress_findings(pipeline_id, pipeline, now_ms) + findings += _counts_findings(pipeline_id, pipeline, now_ms) + findings += _duration_findings(pipeline_id, completed) + findings += _interval_findings(pipeline_id, checkpoints, completed) + findings += _state_findings(pipeline_id, completed) + return sort_findings(findings) diff --git a/seatunnel-cli/seatunnel_cli/diagnostics/jobs.py b/seatunnel-cli/seatunnel_cli/diagnostics/jobs.py new file mode 100644 index 0000000000..1f47e6d7ed --- /dev/null +++ b/seatunnel-cli/seatunnel_cli/diagnostics/jobs.py @@ -0,0 +1,525 @@ +# +# 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. +# + +"""Turn one ``/job-info`` response into findings, without calling a model. + +Every rule here reads a field the engine already publishes. The ``diagnostics`` +block in particular is a dedicated structure -- per-state entry timestamps at +both job and pipeline level, plus the pipeline restore counter -- so the two +questions users actually ask about a non-finishing job can be answered by +arithmetic: + +* "it says RUNNING but nothing is moving" -> a pipeline is restarting in a + loop, visible as ``restoreCount`` climbing toward ``maxRestoreCount`` +* "it has been stuck for ages" -> how long ago the job entered its current + state, from ``stateTimestamps`` + +Both were previously answerable only by reading server logs. The point of +doing this with rules rather than a model is that these answers are derived, +not guessed: a restore count of 7 is a crash loop whatever a model thinks. + +Pure functions over dicts: no I/O, no network, no model calls. Unknown or +missing input yields fewer findings, never a wrong one -- the caller prints +what is here and says nothing about what is not. +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass + +from seatunnel_cli.diagnostics.errors import parse_error + +# How long a job may sit in a non-terminal state before it is worth +# mentioning. Submitting, scheduling and deploying a pipeline legitimately +# takes a few seconds on a cold cluster, and reporting that as "stuck" would +# make the normal case look broken, so the threshold is deliberately well +# above any healthy startup. +STUCK_SECONDS = 120 + +# Restarting once is normal operation -- a transient connection drop costs one +# restore and the job carries on. The signal is repetition, so the crash-loop +# rule only fires from the second restore. +CRASH_LOOP_RESTORES = 2 + +# ``restoreCount`` counts restores since submission, so on its own it says +# nothing about *now*: a streaming job that recovered twice from transient +# errors weeks ago carries the same count as one restarting every minute. The +# distinguishing evidence is how long the pipeline has been in its current +# state -- the engine overwrites the pipeline state timestamps on each +# restore, so a recent entry into RUNNING means the latest attempt is young. +# Ten minutes is comfortably longer than the restart interval of a real loop +# (which is bounded by deploy time, seconds) and comfortably shorter than an +# uptime anyone would call recovered. +CRASH_LOOP_RECENT_SECONDS = 600 + +# The PipelineStatus values, grouped by what a restore count means in each. +# +# This matters because a restorable pipeline is only in an end state for an +# instant: `SubPlan.prepareRestorePipeline` increments the restore counter and +# then immediately `reset()`s the pipeline back toward CREATED, before the +# restore interval elapses. So a pipeline that a response shows *in* an end +# state is not mid-restart -- it is done, and saying "it is restarting again" +# would be false. `PipelineStatus.isEndState()` covers FINISHED, CANCELED and +# FAILED; FAILED is separated here because it is worth a past-tense note when +# the restore budget was spent, while the other two are worth nothing at all. +_PIPELINE_ENDED = {"FINISHED", "CANCELED"} +_PIPELINE_FAILED = {"FAILED"} +_PIPELINE_TEARING_DOWN = {"CANCELING", "FAILING"} +_PIPELINE_RESTARTING = {"CREATED", "SCHEDULED", "DEPLOYING", "INITIALIZING"} + +# The JobStatus values declared EndState.NOT_END, split by what a long stay +# in them actually means. Terminal states are absent on purpose: there, a +# duration is just how long ago the job ended and says nothing about health. +# +# Before running, the usual cause is the cluster not granting the slots the +# job asked for. While tearing down, it is a task that will not stop. A +# savepoint is a third case: it legitimately takes as long as the state is +# large. Those need different wording, because sending someone to check +# cluster resources over a slow savepoint wastes their time. +_PRE_RUNNING = {"INITIALIZING", "CREATED", "PENDING", "SCHEDULED"} +_TEARING_DOWN = {"FAILING", "CANCELING"} +_SAVEPOINT = {"DOING_SAVEPOINT"} +_NON_TERMINAL = _PRE_RUNNING | _TEARING_DOWN | _SAVEPOINT | {"RUNNING"} + +_SEVERITY_ORDER = {"error": 0, "warning": 1, "info": 2} + + +@dataclass(frozen=True) +class Finding: + """One observation about a job. + + ``evidence`` is the field values the rule fired on, kept separate from + ``detail`` so a reader can check the conclusion against the numbers rather + than having to trust it. + """ + + severity: str + rule: str + detail: str + evidence: str | None = None + + def render(self) -> str: + line = f"[{self.severity}] {self.detail}" + return f"{line} ({self.evidence})" if self.evidence else line + + +def is_non_terminal(status) -> bool: + """Whether a job status is one the engine can still move out of. + + Exposed because the caller needs the same notion to decide whether a + missing ``diagnostics`` block is a gap worth reporting: the engine builds + that block only for a job the master still coordinates, so for a finished + or cancelled job its absence is by design rather than lost coverage. + """ + return isinstance(status, str) and status.strip().upper() in _NON_TERMINAL + + +def sort_findings(findings: list[Finding]) -> list[Finding]: + """Worst severity first, stable so equal severities keep insertion order. + + Shared rather than kept per module because the caller prints findings from + several rule sets as a single list: sorting each set on its own would put + an informational note from one above an error from another. + """ + return sorted(findings, key=lambda f: _SEVERITY_ORDER.get(f.severity, 9)) + + +def as_int(value) -> int | None: + """Engine JSON sends counters as ints, but metrics arrive as strings.""" + if isinstance(value, bool) or value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _state_entered_at(timestamps: dict, status: str) -> int | None: + """Epoch millis of when the job entered ``status``, or None if unusable. + + The engine records the *last* entry into each state, keyed by state name, + so the entry for the current status is the right answer even for a job + that was restored and re-entered an earlier state. The map is unordered, + so the fallback when the current status has no entry of its own is the + largest timestamp present -- never the last key, which would be an + arbitrary and possibly very stale state. + """ + if not isinstance(timestamps, dict): + return None + own = as_int(timestamps.get(status)) + if own and own > 0: + return own + values = [v for v in (as_int(v) for v in timestamps.values()) if v and v > 0] + return max(values) if values else None + + +def _humanize(seconds: float) -> str: + seconds = int(seconds) + if seconds < 60: + return f"{seconds}s" + if seconds < 3600: + return f"{seconds // 60}m{seconds % 60:02d}s" + return f"{seconds // 3600}h{(seconds % 3600) // 60:02d}m" + + +def _failure_findings(job_info: dict) -> list[Finding]: + error_msg = job_info.get("errorMsg") + if not isinstance(error_msg, str) or not error_msg.strip(): + return [ + Finding( + "error", + "failed_without_error", + "The job failed but reported no error message; the cause is only in the server log.", + ) + ] + parsed = parse_error(error_msg) + findings = [Finding("error", "job_failed", parsed.headline())] + if parsed.hint: + findings.append(Finding("info", "failure_hint", parsed.hint)) + return findings + + +def _crash_loop_findings(pipelines: list, now_ms: int) -> list[Finding]: + """Restore counters, reported as a live problem only when they are one. + + ``restoreCount`` is cumulative, so what it means depends entirely on the + pipeline's current state. The grouping above encodes the engine's own + lifecycle: a restorable pipeline occupies an end state for an instant + only, because ``SubPlan.prepareRestorePipeline`` increments the counter + and immediately ``reset()``s the pipeline back toward ``CREATED`` before + the restore interval elapses. A pipeline observed in an end state is + therefore finished, not restarting, and its restore count is history. + """ + findings = [] + for pipeline in pipelines: + if not isinstance(pipeline, dict): + continue + restores = as_int(pipeline.get("restoreCount")) + if restores is None or restores < CRASH_LOOP_RESTORES: + continue + pipeline_id = pipeline.get("pipelineId", "?") + limit = as_int(pipeline.get("maxRestoreCount")) + exhausted = limit is not None and limit > 0 and restores >= limit + + status = pipeline.get("pipelineStatus") + status = status.strip().upper() if isinstance(status, str) and status.strip() else None + + evidence = f"restoreCount={restores}" + if limit is not None and limit > 0: + evidence += f"/{limit}" + if status is not None: + evidence += f" pipelineStatus={status}" + + # Finished or cancelled: the pipeline is done. In a multi-pipeline job + # one pipeline finishing after two earlier restores is normal, and + # calling it a crash loop would be plainly wrong. + if status in _PIPELINE_ENDED: + continue + + # Failed: past tense. A failure that was not restorable, or one after + # the budget ran out, is covered by the job-level failure finding; the + # only thing worth adding is that the restores were all spent. + if status in _PIPELINE_FAILED: + if exhausted: + findings.append( + Finding( + "error", + "pipeline_restore_exhausted", + f"Pipeline {pipeline_id} used all {restores} of its restores and " + f"then failed.", + evidence, + ) + ) + continue + + # Budget gone while the pipeline is still live: the engine will not + # restore again, so the next failure is terminal. + if exhausted: + findings.append( + Finding( + "error", + "pipeline_restore_exhausted", + f"Pipeline {pipeline_id} has restarted {restores} times, which is " + f"its limit: the next failure ends the job.", + evidence, + ) + ) + continue + + # Shutting down. Whatever the restores were, they are not what is + # happening now, and the stop may well have been requested. + if status in _PIPELINE_TEARING_DOWN: + findings.append( + Finding( + "info", + "pipeline_restored_before", + f"Pipeline {pipeline_id} restarted {restores} times since it was " + f"submitted and is now {status}.", + evidence, + ) + ) + continue + + # Between attempts: not yet running, with restores behind it, which is + # what a restart in progress looks like. + if status in _PIPELINE_RESTARTING: + findings.append( + Finding( + "warning", + "pipeline_crash_loop", + f"Pipeline {pipeline_id} has restarted {restores} times and is " + f"{status} rather than RUNNING, so it is between attempts rather " + f"than processing data.", + evidence, + ) + ) + continue + + if status == "RUNNING": + attempt_started = _state_entered_at(pipeline.get("stateTimestamps", {}), "RUNNING") + if attempt_started is not None: + uptime_seconds = (now_ms - attempt_started) / 1000.0 + evidence += f" running for {_humanize(uptime_seconds)} since the last restore" + if uptime_seconds >= CRASH_LOOP_RECENT_SECONDS: + # Running steadily since the last restore: the count is a + # record of past incidents, not a description of now. + findings.append( + Finding( + "info", + "pipeline_restored_before", + f"Pipeline {pipeline_id} has restarted {restores} times since " + f"it was submitted, most recently " + f"{_humanize(uptime_seconds)} ago, and has been running since.", + evidence, + ) + ) + continue + # Restarted recently and running again. That the restores are + # still ongoing is not something one snapshot can show, so + # this reports the two facts and points at the logs that can. + findings.append( + Finding( + "warning", + "pipeline_crash_loop", + f"Pipeline {pipeline_id} has restarted {restores} times, and the " + f"current attempt is only {_humanize(uptime_seconds)} old. Check " + f"the task logs for what triggered the restores.", + evidence, + ) + ) + continue + + # No usable status, or RUNNING without a timestamp: the count is all + # there is, so report exactly that and let the reader judge. An + # unproven "crash loop" in the output is worse than a plain number. + findings.append( + Finding( + "info", + "pipeline_restored_before", + f"Pipeline {pipeline_id} has restarted {restores} times since it was " + f"submitted. How recently is not visible in this response.", + evidence, + ) + ) + return findings + + +def _stuck_findings(job_info: dict, diagnostics: dict, now_ms: int) -> list[Finding]: + status = job_info.get("jobStatus") + if not isinstance(status, str): + return [] + status = status.upper() + if status not in _NON_TERMINAL: + return [] + + entered = _state_entered_at(diagnostics.get("stateTimestamps", {}), status) + if entered is None: + return [] + held_seconds = (now_ms - entered) / 1000.0 + if held_seconds < STUCK_SECONDS: + return [] + + held = _humanize(held_seconds) + if status == "RUNNING": + # RUNNING for a long time is what a streaming job is supposed to do, + # so this is only worth a line of context, never a warning. + return [Finding("info", "running_duration", f"Running for {held}.", f"state={status}")] + if status in _PRE_RUNNING: + return [ + Finding( + "warning", + "stuck_before_running", + f"The job has been {status} for {held} without starting. That usually " + f"means the cluster could not give it the slots it asked for -- check " + f"free slots and worker count on the cluster.", + f"state={status}", + ) + ] + if status in _SAVEPOINT: + # A savepoint writes out the whole job state, so a large state takes + # minutes by design. Calling that "not responding" sends someone + # looking for a hung task when the job is doing exactly what it was + # asked to do, so this only reports the duration. + return [ + Finding( + "warning", + "savepoint_in_progress", + f"The job has been {status} for {held}. A savepoint takes as long as " + f"the job state is large, so this can be normal; if it does not finish, " + f"a task is not completing its snapshot.", + f"state={status}", + ) + ] + return [ + Finding( + "warning", + "stuck_tearing_down", + f"The job has been {status} for {held}. It is shutting down and not " + f"finishing, which means a task is not responding to the stop request.", + f"state={status}", + ) + ] + + +def _is_streaming(job_info: dict) -> bool: + """Whether the job declares ``job.mode = STREAMING`` in its env options. + + Only used to pick wording. A streaming source that has not produced a row + yet may simply have nothing to produce -- a quiet topic, a CDC job waiting + for its first change -- while a batch source that reads nothing has a + problem by definition. + """ + env = job_info.get("envOptions") + mode = env.get("job.mode") if isinstance(env, dict) else None + return isinstance(mode, str) and mode.strip().upper() == "STREAMING" + + +def _progress_findings(job_info: dict, diagnostics: dict, now_ms: int) -> list[Finding]: + """Row counts, read from the job-wide scalar metrics. + + ``SourceReceivedCount``/``SinkWriteCount`` are the cluster-wide totals. + The ``Table``-prefixed names are deliberately *not* used here: those are + objects keyed by table name, not scalars, and every metric leaf is + rendered as a string by the engine, so both the nesting and the quoting + have to be respected rather than assumed. + + Both rules are gated on how long the job has been RUNNING. A zero counter + a second after the job started running is the expected reading, not a + symptom, and reporting it would make every freshly submitted job look + broken. + """ + if job_info.get("jobStatus") != "RUNNING": + return [] + metrics = job_info.get("metrics") + if not isinstance(metrics, dict): + return [] + read = as_int(metrics.get("SourceReceivedCount")) + written = as_int(metrics.get("SinkWriteCount")) + if read is None or written is None: + return [] + + # The RUNNING entry specifically, not the newest timestamp of any state: + # without it there is no way to tell a job that has read nothing for an + # hour from one that started two seconds ago, and guessing is what these + # rules are supposed to avoid. + timestamps = diagnostics.get("stateTimestamps") + entered = as_int(timestamps.get("RUNNING")) if isinstance(timestamps, dict) else None + if entered is None or entered <= 0: + return [] + running_seconds = (now_ms - entered) / 1000.0 + if running_seconds < STUCK_SECONDS: + return [] + running_for = _humanize(running_seconds) + + if read == 0: + if _is_streaming(job_info): + return [ + Finding( + "info", + "no_rows_read", + f"The source has produced no rows in the {running_for} since the job " + f"started running. For a streaming source with nothing to read yet -- " + f"a quiet topic, a CDC job waiting for its first change -- that is " + f"normal; if you expected rows by now, check the table or path it " + f"points at and any filter on the source.", + f"SourceReceivedCount=0 running for {running_for}", + ) + ] + return [ + Finding( + "warning", + "no_rows_read", + f"The source has produced no rows in the {running_for} since the job " + f"started running. Check the table or path it points at, and any filter " + f"or where clause on the source.", + f"SourceReceivedCount=0 running for {running_for}", + ) + ] + if written == 0: + return [ + Finding( + "warning", + "rows_read_none_written", + f"The source read {read} rows but the sink wrote none, so rows are " + "being dropped between them -- usually a transform filter, or a " + "sink that has not committed its first batch yet.", + f"SourceReceivedCount={read} SinkWriteCount=0 running for {running_for}", + ) + ] + return [] + + +def diagnose_job(job_info: dict, *, now_ms: int | None = None) -> list[Finding]: + """Apply every rule to one ``/job-info`` response, worst finding first. + + ``now_ms`` is injectable because the stuck-state rule is a clock + comparison, and a test that cannot pin the clock can only assert on + thresholds it has to reach by sleeping. + + When it is not injected, ``diagnostics.generatedAt`` is preferred over the + local clock: every timestamp it is compared against was written by the + master, so using the master's own "now" keeps the arithmetic inside one + clock. A laptop a few minutes off a cluster would otherwise invent a + stuck job, or hide one. + """ + if not isinstance(job_info, dict): + return [] + + diagnostics = job_info.get("diagnostics") + diagnostics = diagnostics if isinstance(diagnostics, dict) else {} + pipelines = diagnostics.get("pipelines") + pipelines = pipelines if isinstance(pipelines, list) else [] + + if now_ms is None: + generated_at = as_int(diagnostics.get("generatedAt")) + now_ms = ( + generated_at + if generated_at is not None and generated_at > 0 + else int(time.time() * 1000) + ) + + findings: list[Finding] = [] + if job_info.get("jobStatus") == "FAILED": + findings += _failure_findings(job_info) + findings += _crash_loop_findings(pipelines, now_ms) + findings += _stuck_findings(job_info, diagnostics, now_ms) + findings += _progress_findings(job_info, diagnostics, now_ms) + + # Stable sort, so rules of equal severity keep the order above: failure + # first, then why it is not progressing, then throughput. + return sort_findings(findings) diff --git a/seatunnel-cli/tests/test_diagnose_checkpoints.py b/seatunnel-cli/tests/test_diagnose_checkpoints.py new file mode 100644 index 0000000000..2ecfc513d6 --- /dev/null +++ b/seatunnel-cli/tests/test_diagnose_checkpoints.py @@ -0,0 +1,576 @@ +# +# 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 checkpoint-history rules. + +The payloads are shaped the way `/jobs/checkpoints/{jobId}` actually sends +them, and three of those details are what a diagnosis built on assumptions +would get wrong: + +* `history` is **newest first**, so a trend read in array order is inverted; +* failed and canceled checkpoints share the array but have **no** + `durationMillis` (the field is omitted when null); +* their `stateSize` is a primitive `long`, so it arrives as a plausible `0` + rather than being absent, which would make growing state look like + shrinking state. + +Each of those has a test of its own below, because all three fail silently -- +they produce a confident finding that is simply backwards. +""" + +import time + +from seatunnel_cli.diagnostics.checkpoints import ( + DURATION_FLOOR_MS, + MIN_TREND_SAMPLES, + MIN_TRIGGERS_NEVER_COMPLETED, + STALLED_CHECKPOINT_SECONDS, + diagnose_checkpoints, +) + +NOW = 1_760_000_000_000 + + +def _rules(findings): + return [f.rule for f in findings] + + +def _by_rule(findings, rule): + return next(f for f in findings if f.rule == rule) + + +def _history(durations, *, state_sizes=None, interval_ms=600_000, status="COMPLETED"): + """Build a newest-first history array. + + ``durations`` is given newest first, matching the wire format, so a test + that wants "recently slow" puts the large values at the front. + """ + entries = [] + for index, duration in enumerate(durations): + checkpoint = { + "checkpointId": 1000 - index, + "checkpointType": "CHECKPOINT_TYPE", + "status": status, + # Newest first, so each successive entry is older. + "triggerTimestamp": NOW - index * interval_ms, + "stateSize": (state_sizes[index] if state_sizes else 1024), + } + if duration is not None: + checkpoint["durationMillis"] = duration + checkpoint["completedTimestamp"] = checkpoint["triggerTimestamp"] + duration + entries.append({"pipelineId": 1, "checkpoint": checkpoint}) + return entries + + +def _overview(pipeline): + return {"jobId": "852", "updatedAt": NOW, "pipelines": [pipeline]} + + +def test_checkpoints_getting_slower_are_reported(): + findings = diagnose_checkpoints( + _overview({"pipelineId": 1, "history": _history([30_000] * 4 + [6_000] * 4)}), + now_ms=NOW, + ) + + growing = _by_rule(findings, "checkpoint_duration_growing") + assert growing.severity == "warning" + assert "30.0s" in growing.detail and "6.0s" in growing.detail + + +def test_checkpoints_getting_faster_are_not_reported_as_slower(): + # This is the ordering trap. `history` is newest first, so these durations + # describe a job that *recovered*: it used to take 30s and now takes 6s. + # Reading the array as oldest-first would report a 5x slowdown. + findings = diagnose_checkpoints( + _overview({"pipelineId": 1, "history": _history([6_000] * 4 + [30_000] * 4)}), + now_ms=NOW, + ) + + assert "checkpoint_duration_growing" not in _rules(findings) + + +def test_failed_checkpoints_do_not_poison_the_duration_trend(): + # Failed entries carry no durationMillis at all. Treating the missing + # field as a zero would halve the recent median and hide a real slowdown. + history = _history([30_000, None, 30_000, None, 30_000, 30_000, 6_000, 6_000, 6_000, 6_000]) + for entry in history: + if "durationMillis" not in entry["checkpoint"]: + entry["checkpoint"]["status"] = "FAILED" + entry["checkpoint"]["failureReason"] = "checkpoint expired" + + findings = diagnose_checkpoints(_overview({"pipelineId": 1, "history": history}), now_ms=NOW) + + assert "checkpoint_duration_growing" in _rules(findings) + + +def test_failed_checkpoints_zero_state_size_does_not_invert_the_state_trend(): + # stateSize is a primitive long on the engine side, so a failed checkpoint + # reports a convincing 0 rather than omitting the field. Counted in, those + # zeroes would drag the recent median down and turn growing state into + # apparently shrinking state. + sizes = [50 << 20] * 4 + [10 << 20] * 4 + history = _history([30_000] * 8, state_sizes=sizes) + # Two recent failures, which is exactly when this is most likely to bite. + for entry in history[:2]: + entry["checkpoint"]["status"] = "FAILED" + entry["checkpoint"]["stateSize"] = 0 + del entry["checkpoint"]["durationMillis"] + + findings = diagnose_checkpoints(_overview({"pipelineId": 1, "history": history}), now_ms=NOW) + + assert "checkpoint_state_growing" in _rules(findings) + + +def test_a_doubling_of_a_trivial_duration_is_not_reported(): + # 8ms to 20ms is a doubling and is not why anything is lagging. + findings = diagnose_checkpoints( + _overview( + {"pipelineId": 1, "history": _history([20] * 4 + [8] * 4, interval_ms=10_000)} + ), + now_ms=NOW, + ) + + assert "checkpoint_duration_growing" not in _rules(findings) + assert DURATION_FLOOR_MS > 20 + + +def test_too_few_checkpoints_yields_no_trend(): + findings = diagnose_checkpoints( + _overview( + {"pipelineId": 1, "history": _history([30_000, 30_000, 6_000, 6_000])}, + ), + now_ms=NOW, + ) + + assert MIN_TREND_SAMPLES > 4 + assert "checkpoint_duration_growing" not in _rules(findings) + + +def test_a_job_that_spends_its_time_checkpointing_is_reported_without_a_baseline(): + # Durations are flat, so the trend rule stays silent -- this is the job + # that degraded before the retained window began and has been slow since. + findings = diagnose_checkpoints( + _overview( + {"pipelineId": 1, "history": _history([9_000] * 8, interval_ms=10_000)}, + ), + now_ms=NOW, + ) + + assert "checkpoint_duration_growing" not in _rules(findings) + bound = _by_rule(findings, "checkpoint_bound") + assert "9.0s" in bound.detail and "10.0s" in bound.detail + + +def test_a_job_that_became_checkpoint_bound_recently_is_still_reported(): + # Healthy for the older half (4s of a 60s interval), checkpoint-bound in + # the recent half (48s of 60s). A median over the whole window is 26s, + # which is under the threshold, so taking it would stay silent exactly + # when the job has just gone bad. + findings = diagnose_checkpoints( + _overview( + { + "pipelineId": 1, + "history": _history([48_000] * 8 + [4_000] * 8, interval_ms=60_000), + } + ), + now_ms=NOW, + ) + + bound = _by_rule(findings, "checkpoint_bound") + assert "48.0s" in bound.detail and "1m00s" in bound.detail + + +def test_checkpoints_that_finish_well_within_the_interval_are_not_flagged(): + findings = diagnose_checkpoints( + _overview( + {"pipelineId": 1, "history": _history([1_000] * 8, interval_ms=60_000)}, + ), + now_ms=NOW, + ) + + assert findings == [] + + +def test_a_stalled_checkpoint_names_the_subtasks_holding_the_barrier(): + findings = diagnose_checkpoints( + _overview( + { + "pipelineId": 2, + "inProgress": [ + { + "checkpointId": 77, + "triggerTimestamp": NOW - (STALLED_CHECKPOINT_SECONDS + 300) * 1000, + "acknowledged": 3, + "total": 8, + } + ], + } + ), + now_ms=NOW, + ) + + stalled = _by_rule(findings, "checkpoint_stalled") + assert stalled.severity == "error" + assert "3 of 8" in stalled.detail + assert "remaining 5" in stalled.detail + assert "acknowledged=3/8" in stalled.evidence + + +def test_a_checkpoint_that_just_started_is_not_called_stalled(): + findings = diagnose_checkpoints( + _overview( + { + "pipelineId": 1, + "inProgress": [ + {"checkpointId": 78, "triggerTimestamp": NOW - 3000, "acknowledged": 1, + "total": 8}, + ], + } + ), + now_ms=NOW, + ) + + assert findings == [] + + +def test_never_having_completed_a_checkpoint_is_reported(): + findings = diagnose_checkpoints( + _overview( + { + "pipelineId": 1, + "counts": {"triggered": 12, "completed": 0, "failed": 0, "inProgress": 1}, + } + ), + now_ms=NOW, + ) + + never = _by_rule(findings, "checkpoint_never_completed") + assert never.evidence == "triggered=12 completed=0" + + +def test_a_job_in_its_first_checkpoint_interval_is_not_reported_as_never_completing(): + # triggered=1, completed=0, one in flight is what every healthy job looks + # like before its first checkpoint lands. Saying "nothing has been + # committed, a restart replays from the beginning" there describes normal + # startup as a fault. + findings = diagnose_checkpoints( + _overview( + { + "pipelineId": 1, + "counts": {"triggered": 1, "completed": 0, "failed": 0, "inProgress": 1}, + "inProgress": [{"checkpointId": 1, "triggerTimestamp": NOW - 3_000}], + } + ), + now_ms=NOW, + ) + + assert findings == [] + + +def test_a_first_checkpoint_that_is_already_overdue_is_reported(): + # One trigger is still enough once that checkpoint has been in flight + # longer than any healthy one would be. + findings = diagnose_checkpoints( + _overview( + { + "pipelineId": 1, + "counts": {"triggered": 1, "completed": 0, "failed": 0, "inProgress": 1}, + "inProgress": [ + { + "checkpointId": 1, + "triggerTimestamp": NOW - (STALLED_CHECKPOINT_SECONDS + 10) * 1000, + } + ], + } + ), + now_ms=NOW, + ) + + never = _by_rule(findings, "checkpoint_never_completed") + assert "oldest in progress for" in never.evidence + + +def test_a_second_trigger_with_nothing_completed_is_reported_without_timestamps(): + # An engine that sends counts but no inProgress detail still supports the + # "two rounds, nothing committed" reading. + findings = diagnose_checkpoints( + _overview( + { + "pipelineId": 1, + "counts": { + "triggered": MIN_TRIGGERS_NEVER_COMPLETED, + "completed": 0, + "failed": 0, + }, + } + ), + now_ms=NOW, + ) + + assert "checkpoint_never_completed" in _rules(findings) + + +def test_a_stuck_checkpoint_is_detected_even_though_updated_at_is_stale(): + # `updatedAt` looks like a response timestamp and is not one: the engine + # writes it inside HazelcastCheckpointOverviewStateStore.updateOverview, + # so only when a checkpoint event is recorded. For a checkpoint hung on + # its last subtask the newest write is that last acknowledgement, a few + # seconds after the trigger, and nothing follows. Taking it as "now" + # freezes the age there, and the stalled rule -- the whole point of which + # is a hung barrier -- could never fire. + trigger = NOW - (STALLED_CHECKPOINT_SECONDS + 30) * 1000 + overview = { + "jobId": "852", + "updatedAt": trigger + 3_000, + "pipelines": [ + { + "pipelineId": 1, + "inProgress": [ + { + "checkpointId": 7, + "triggerTimestamp": trigger, + "acknowledged": 3, + "total": 8, + } + ], + } + ], + } + + stalled = _by_rule(diagnose_checkpoints(overview, now_ms=NOW), "checkpoint_stalled") + assert "2m30s" in stalled.detail + assert "3 of 8 subtasks" in stalled.detail + + +def test_without_an_injected_clock_the_local_one_is_used_not_updated_at(): + # The fallback has to be the local clock. If `updatedAt` were consulted + # here the age would come out as 3s and the rule would stay silent. + now = int(time.time() * 1000) + trigger = now - (STALLED_CHECKPOINT_SECONDS + 60) * 1000 + overview = { + "jobId": "852", + "updatedAt": trigger + 3_000, + "pipelines": [ + {"pipelineId": 1, "inProgress": [{"checkpointId": 7, "triggerTimestamp": trigger}]} + ], + } + + assert "checkpoint_stalled" in _rules(diagnose_checkpoints(overview)) + + +def test_failed_checkpoints_are_reported_with_the_engine_reason(): + findings = diagnose_checkpoints( + _overview( + { + "pipelineId": 1, + "counts": {"triggered": 40, "completed": 36, "failed": 4}, + "latestCompleted": {"checkpointId": 39, "triggerTimestamp": NOW - 120_000}, + "latestFailed": { + "checkpointId": 40, + "status": "FAILED", + "triggerTimestamp": NOW - 60_000, + "failureReason": "checkpoint expired before completing", + }, + } + ), + now_ms=NOW, + ) + + failing = _by_rule(findings, "checkpoint_failing") + assert "checkpoint expired before completing" in failing.detail + assert failing.evidence == "failed=4 completed=36" + + +def test_old_failures_under_a_newer_completion_are_not_a_warning(): + # `counts.failed` never goes down, so one expired checkpoint keeps it above + # zero for the rest of the job's life. A long-running pipeline that has + # completed thousands of checkpoints since is committing progress, and + # telling its owner it makes "no durable progress" is simply false. + findings = diagnose_checkpoints( + _overview( + { + "pipelineId": 1, + "counts": {"triggered": 5000, "completed": 4999, "failed": 1}, + "latestFailed": { + "checkpointId": 12, + "triggerTimestamp": NOW - 20 * 86400 * 1000, + "failureReason": "CHECKPOINT_EXPIRED", + }, + "latestCompleted": {"checkpointId": 5000, "triggerTimestamp": NOW - 10_000}, + } + ), + now_ms=NOW, + ) + + assert "checkpoint_failing" not in _rules(findings) + note = _by_rule(findings, "checkpoint_failed_before") + assert note.severity == "info" + assert "committing progress now" in note.detail + assert "no durable progress" not in note.detail + + +def test_a_failure_newer_than_the_last_completion_is_a_warning(): + findings = diagnose_checkpoints( + _overview( + { + "pipelineId": 1, + "counts": {"triggered": 5000, "completed": 4990, "failed": 10}, + "latestCompleted": {"checkpointId": 4990, "triggerTimestamp": NOW - 600_000}, + "latestFailed": {"checkpointId": 5000, "triggerTimestamp": NOW - 5_000}, + } + ), + now_ms=NOW, + ) + + failing = _by_rule(findings, "checkpoint_failing") + assert failing.severity == "warning" + assert "latest checkpoint failed" in failing.detail + + +def test_failures_with_no_completion_on_record_are_a_warning(): + # Nothing has ever completed, so the latest failure is the only outcome + # there is. + findings = diagnose_checkpoints( + _overview( + { + "pipelineId": 1, + "counts": {"triggered": 3, "completed": 0, "failed": 3}, + "latestFailed": {"checkpointId": 3, "triggerTimestamp": NOW - 5_000}, + } + ), + now_ms=NOW, + ) + + assert _by_rule(findings, "checkpoint_failing").severity == "warning" + + +def test_an_unknown_completed_count_is_not_reported_as_zero(): + # A count the engine did not send, or sent unusably, is unknown. Saying + # "completed none" from it would present a guess as a measurement. + for completed in (None, "x", {}): + findings = diagnose_checkpoints( + _overview( + {"pipelineId": 1, "counts": {"triggered": 12, "completed": completed}} + ), + now_ms=NOW, + ) + + assert "checkpoint_never_completed" not in _rules(findings) + + +def test_byte_sizes_are_rendered_at_a_readable_scale(): + findings = diagnose_checkpoints( + _overview( + { + "pipelineId": 1, + "history": _history( + [30_000] * 8, state_sizes=[6 << 30] * 4 + [1 << 30] * 4 + ), + } + ), + now_ms=NOW, + ) + + growing = _by_rule(findings, "checkpoint_state_growing") + assert "6.0GiB" in growing.detail and "1.0GiB" in growing.detail + + +def test_a_cluster_with_no_checkpoint_monitor_data_yields_nothing(): + # The endpoint answers with just the jobId when the monitor service is not + # running or has nothing for this job. That is a normal state, not an error. + assert diagnose_checkpoints({"jobId": "852"}, now_ms=NOW) == [] + + +def test_findings_from_several_pipelines_are_ordered_worst_first(): + findings = diagnose_checkpoints( + { + "jobId": "852", + "pipelines": [ + {"pipelineId": 1, "history": _history([30_000] * 4 + [6_000] * 4)}, + { + "pipelineId": 2, + "inProgress": [ + { + "checkpointId": 9, + "triggerTimestamp": NOW - (STALLED_CHECKPOINT_SECONDS + 10) * 1000, + "acknowledged": 0, + "total": 4, + } + ], + }, + ], + }, + now_ms=NOW, + ) + + # The stalled checkpoint on pipeline 2 is an error and must outrank the + # slowdown warning on pipeline 1, even though pipeline 1 is listed first. + assert findings[0].rule == "checkpoint_stalled" + assert "checkpoint_duration_growing" in _rules(findings) + + +def test_malformed_payloads_yield_no_findings_rather_than_raising(): + for payload in ( + None, + [], + "nope", + {}, + {"pipelines": None}, + {"pipelines": "none"}, + {"pipelines": [None, 7, "x"]}, + {"pipelines": [{"pipelineId": 1, "history": "none"}]}, + {"pipelines": [{"pipelineId": 1, "history": [None, 3]}]}, + {"pipelines": [{"pipelineId": 1, "history": [{"checkpoint": "no"}]}]}, + {"pipelines": [{"pipelineId": 1, "counts": "none"}]}, + {"pipelines": [{"pipelineId": 1, "counts": {"triggered": "x", "completed": None}}]}, + {"pipelines": [{"pipelineId": 1, "inProgress": "none"}]}, + {"pipelines": [{"pipelineId": 1, "inProgress": [None, 2]}]}, + {"pipelines": [{"pipelineId": 1, "inProgress": [{"triggerTimestamp": "soon"}]}]}, + ): + assert diagnose_checkpoints(payload, now_ms=NOW) == [] + + +def test_a_failed_count_still_reports_when_the_reason_is_unusable(): + # latestFailed can be an empty object, since the engine omits null fields, + # and a count of failures is worth reporting with or without a reason -- + # dropping the finding because the explanation is missing would discard the + # part that was actually measured. + for latest_failed in ({}, "none", None, 7): + findings = diagnose_checkpoints( + _overview( + {"pipelineId": 1, "counts": {"failed": 2}, "latestFailed": latest_failed} + ), + now_ms=NOW, + ) + + # Without a timestamp there is no evidence about the latest attempt, + # so the count is reported as a count rather than as a live fault. + assert _rules(findings) == ["checkpoint_failed_before"] + assert findings[0].severity == "info" + assert "not visible in this response" in findings[0].detail + assert "Last failure" not in findings[0].detail + + +def test_identical_timestamps_do_not_divide_by_zero(): + # A clock with coarse resolution, or a burst of savepoints, can give + # several entries the same trigger timestamp. + findings = diagnose_checkpoints( + _overview({"pipelineId": 1, "history": _history([9_000] * 8, interval_ms=0)}), + now_ms=NOW, + ) + + assert "checkpoint_bound" not in _rules(findings) diff --git a/seatunnel-cli/tests/test_diagnose_cli.py b/seatunnel-cli/tests/test_diagnose_cli.py new file mode 100644 index 0000000000..8962a6de0a --- /dev/null +++ b/seatunnel-cli/tests/test_diagnose_cli.py @@ -0,0 +1,575 @@ +# +# 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. +# + +"""End-to-end tests for ``seatunnel --diagnose <jobId>``. + +These drive `run_diagnose` against a real loopback HTTP server so the whole +path is covered: URL construction, the REST call, the rules, redaction and the +exit code. No LLM provider is constructed anywhere here, which is itself part +of what is being asserted -- the flag has to work for a user with no API key. +""" + +import json +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + +import pytest +from rich.console import Console + +from seatunnel_cli import cli as cli_module + + +class _Handler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_GET(self): + self.server.paths.append(self.path) + status, body = self.server.script(self) + payload = body.encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + [email protected] +def engine(monkeypatch): + httpd = HTTPServer(("127.0.0.1", 0), _Handler) + httpd.paths = [] + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + base = f"http://127.0.0.1:{httpd.server_port}" + # `connectors` resolves the base at import time, so patch the module + # attribute rather than the environment. + monkeypatch.setattr("seatunnel_cli.connectors._ENGINE_API_BASE", base) + try: + yield httpd + finally: + httpd.shutdown() + httpd.server_close() + + +def _run(job_id="852"): + console = Console(theme=cli_module.THEME, width=200, record=True) + code = cli_module.run_diagnose(job_id, console) + return code, " ".join(console.export_text().split()) + + +def test_a_failed_job_prints_the_error_code_and_exits_nonzero(engine): + engine.script = lambda h: ( + 200, + json.dumps( + { + "jobId": "852", + "jobName": "mysql-to-doris", + "jobStatus": "FAILED", + "errorMsg": ( + "Caused by: org.apache.seatunnel.common.exception.SeaTunnelRuntimeException: " + "ErrorCode:[JDBC-05], ErrorDescription:[Connection failed]" + ), + } + ), + ) + + code, out = _run() + + assert code == 1 + assert "mysql-to-doris" in out + assert "JDBC-05" in out + # Both reads are issued, and both are GETs, so the command stays read-only. + assert engine.paths == ["/job-info/852", "/jobs/checkpoints/852"] + + +_NOW = 1_760_000_000_000 + + +def _crash_looping_job(name="mysql-to-doris"): + # `generatedAt` is what the rules use as "now", so pinning it here makes + # the restore recency deterministic without touching the system clock. + return json.dumps( + { + "jobId": "852", + "jobName": name, + "jobStatus": "RUNNING", + "diagnostics": { + "generatedAt": _NOW, + "pipelines": [ + { + "pipelineId": 1, + "restoreCount": 5, + "maxRestoreCount": 10, + "pipelineStatus": "RUNNING", + "stateTimestamps": {"RUNNING": _NOW - 20_000}, + } + ], + }, + } + ) + + +def test_a_crash_looping_job_is_reported_but_exits_zero(engine): + # The job is alive, so this must not look like a command failure. + engine.script = lambda h: (200, _crash_looping_job()) + + code, out = _run() + + assert code == 0 + assert "restarted 5 times" in out + + +def test_the_severity_label_reaches_the_terminal(engine): + # `Finding.render()` prefixes "[warning]", and "warning" is a style name in + # THEME, so with rich markup enabled the label is consumed as a tag and the + # severity survives only as colour -- invisible in a pipe, in CI logs and + # under NO_COLOR, which is where this output usually ends up. + engine.script = lambda h: (200, _crash_looping_job()) + + _, out = _run() + + assert "[warning]" in out + + +def test_bracketed_engine_text_is_not_swallowed(engine): + # A SeaTunnel error message is full of lowercase bracketed fields, and + # those look exactly like style tags. + engine.script = lambda h: ( + 200, + json.dumps( + { + "jobId": "852", + "jobStatus": "FAILED", + "errorMsg": "ErrorCode:[JDBC-05], ErrorDescription:[connection refused]", + } + ), + ) + + code, out = _run() + + assert code == 1 + assert "[error]" in out + assert "connection refused" in out + + +def test_a_path_that_looks_like_a_closing_tag_does_not_crash(engine): + # "[/data/x.csv]" parses as a closing tag and raises MarkupError, which + # would crash the command in the one situation it exists for. + engine.script = lambda h: ( + 200, + json.dumps( + { + "jobId": "852", + "jobStatus": "FAILED", + "errorMsg": "java.io.IOException: cannot write [/data/x.csv]", + } + ), + ) + + code, out = _run() + + assert code == 1 + assert "/data/x.csv" in out + + +def test_a_bracketed_job_name_is_not_read_as_markup(engine): + # The job name comes from whoever submitted the job. + engine.script = lambda h: (200, _crash_looping_job(name="[prod] nightly")) + + _, out = _run() + + assert "[prod] nightly" in out + + +def test_a_non_numeric_job_id_is_rejected_before_any_request(engine): + # The engine parses a job id as a decimal long, so "1/2" would be sent as + # the path /job-info/1/2 and come back as a 404 about a missing job. + engine.script = lambda h: (200, "{}") + + code, out = _run("1/2") + + assert code == 1 + assert "is not a job id" in out + assert engine.paths == [] + + +def test_a_healthy_job_says_nothing_to_report(engine): + engine.script = lambda h: (200, json.dumps({"jobId": "852", "jobStatus": "FINISHED"})) + + code, out = _run() + + assert code == 0 + assert "Nothing to report" in out + + +def test_an_unknown_job_id_is_reported_from_the_response_the_engine_really_sends(engine): + # The decisive case, and not the obvious one: `JobInfoService` does not + # 404 for an id it does not know. When the job is in neither the + # running-job map nor the finished-job state it falls through to + # `{"jobId": "<id>"}` with HTTP 200. Treated as a job, that response has + # no status, no rule matches, and the output used to read "Nothing to + # report" -- telling someone their typo'd job id is healthy. + engine.script = lambda h: (200, json.dumps({"jobId": "999999999999999999"})) + + code, out = _run("999999999999999999") + + assert code == 1 + assert "No job 999999999999999999" in out + assert "Nothing to report" not in out + + +def test_a_non_object_json_body_does_not_crash(engine): + # A bare `null` or a list (a proxy, or a future response shape) reaches + # the same path and must not raise AttributeError. + for body in ("null", "[]", '"a string"'): + engine.script = lambda h, b=body: (200, b) + + code, out = _run() + + assert code == 1 + assert "No job 852" in out + + +def test_a_real_404_is_still_handled(engine): + # Jetty does not send it for a missing job, but a proxy or a different + # engine build can, and that branch still has to say something useful. + engine.script = lambda h: (404, "job not found") + + code, out = _run("999999999999999999") + + assert code == 1 + assert "No job 999999999999999999" in out + # A 404 is also what a base URL pointing at the member port rather than + # the Jetty HTTP port looks like, so the message has to offer that way out. + assert "SEATUNNEL_API_BASE" in out + + +def test_a_running_job_without_diagnostics_says_the_checks_did_not_run(engine): + # The engine omits the `diagnostics` block when the master cannot serve + # it, which leaves the crash-loop and stuck-state rules with no input. + # Silence there is the same false reassurance as the unknown-id case. + engine.script = lambda h: ( + 200, + json.dumps({"jobId": "852", "jobStatus": "RUNNING", "metrics": {}}), + ) + + code, out = _run() + + assert code == 0 + assert "Restart counts and state ages were not available" in out + assert "Nothing to report" not in out + + +def test_a_finished_job_without_diagnostics_is_not_warned_about(engine): + # A terminal job legitimately has no diagnostics block: the engine builds + # it only for a job the master still coordinates. Reporting that as a gap + # would fire on every finished job. + engine.script = lambda h: (200, json.dumps({"jobId": "852", "jobStatus": "FINISHED"})) + + code, out = _run() + + assert code == 0 + assert "were not available" not in out + assert "Nothing to report" in out + + +def test_the_http_error_body_is_redacted_and_capped(engine): + # The body is printed, so it needs the same treatment as the findings: + # it can quote a JDBC URL with a password, and rest.py only bounds it at + # 8 MiB, which is not a terminal-sized amount of text. + secret = "jdbc:mysql://db:3306/app?user=root&password=sup3rs3cret" + engine.script = lambda h: (500, f"submit failed for {secret} " + ("x" * 5000)) + + code, out = _run() + + assert code == 1 + assert "sup3rs3cret" not in out + assert "chars total" in out + assert len(out) < 5000 + + +def test_other_http_errors_show_the_engine_body(engine): + engine.script = lambda h: (500, "ErrorCode:[API-02], ErrorDescription:[master is down]") + + code, out = _run() + + assert code == 1 + # The bracketed description is the useful half of the body; asserting only + # on "API-02" is what let the markup bug through in the first place. + assert "ErrorDescription:[master is down]" in out + + +def test_an_unreachable_engine_names_the_override_env_var(monkeypatch): + # Nothing is listening on port 1, so this is the real "engine is down" + # path, not a simulated one. + monkeypatch.setattr("seatunnel_cli.connectors._ENGINE_API_BASE", "http://127.0.0.1:1") + + code, out = _run() + + assert code == 1 + assert "SEATUNNEL_API_BASE" in out + + +def test_credentials_in_the_error_message_are_redacted(engine): + # A JDBC failure quotes the URL it tried, password included, and this + # output routinely gets pasted into a bug report. + engine.script = lambda h: ( + 200, + json.dumps( + { + "jobId": "852", + "jobStatus": "FAILED", + "errorMsg": ( + "Caused by: java.sql.SQLException: Could not connect to " + "jdbc:mysql://db:3306/app?user=root&password=sup3rs3cret" + ), + } + ), + ) + + code, out = _run() + + assert code == 1 + assert "sup3rs3cret" not in out + + +def _routed(job_info, checkpoints=None, checkpoint_status=200): + """Serve job-info and the checkpoint overview from one fake engine.""" + + def script(handler): + if handler.path.startswith("/jobs/checkpoints/"): + if checkpoints is None: + return checkpoint_status, "no checkpoint data" + return checkpoint_status, json.dumps(checkpoints) + return 200, json.dumps(job_info) + + return script + + +def _slow_checkpoint_history(count=8): + # Newest first, matching the engine. Recent checkpoints take 40s, older + # ones took 4s, so the job has visibly got slower. + now = 1_760_000_000_000 + entries = [] + for index in range(count): + duration = 40_000 if index < count // 2 else 4_000 + entries.append( + { + "pipelineId": 1, + "checkpoint": { + "checkpointId": 500 - index, + "status": "COMPLETED", + "triggerTimestamp": now - index * 600_000, + "completedTimestamp": now - index * 600_000 + duration, + "durationMillis": duration, + "stateSize": 4096, + }, + } + ) + return entries + + +def test_a_slowing_cdc_job_is_explained_even_though_rows_still_flow(engine): + # This is the case job-info alone cannot answer: RUNNING, both counters + # moving, no restores. Without the checkpoint read the only output would + # be "Running for ...", which restates the symptom. + engine.script = _routed( + { + "jobId": "852", + "jobName": "mysql-cdc-to-doris", + "jobStatus": "RUNNING", + "metrics": {"SourceReceivedCount": "184203311", "SinkWriteCount": "184201902"}, + }, + {"jobId": "852", "pipelines": [{"pipelineId": 1, "history": _slow_checkpoint_history()}]}, + ) + + code, out = _run() + + assert code == 0 + assert "getting slower" in out + assert "40.0s" in out and "4.0s" in out + + +def test_a_missing_checkpoint_endpoint_does_not_break_the_diagnosis(engine): + # An older engine has no such endpoint. The job diagnosis must survive it, + # because losing the whole answer to gain one optional rule is a bad trade. + engine.script = _routed( + { + "jobId": "852", + "jobStatus": "FAILED", + "errorMsg": "ErrorCode:[JDBC-05], ErrorDescription:[Connection failed]", + }, + checkpoint_status=404, + ) + + code, out = _run() + + assert code == 1 + assert "JDBC-05" in out + # No notice for a terminal job: a finished or failed job legitimately has + # no checkpoint overview retained, so reporting its absence would fire on + # every one of them. The live-job case is covered below. + assert "No checkpoint history" not in out + + +def test_a_healthy_job_with_no_checkpoint_data_does_not_look_fully_checked(engine): + # The combination that matters: the job-level rules ran and found nothing, + # while the checkpoint read failed. Without the notice this prints only + # "Nothing to report", which reads as "the slowdown checks ran and found + # nothing". The job carries a diagnostics block so that this test isolates + # the checkpoint gap rather than also tripping the job-level one. + engine.script = _routed( + { + "jobId": "852", + "jobStatus": "RUNNING", + "diagnostics": {"generatedAt": _NOW, "stateTimestamps": {"RUNNING": _NOW - 5_000}}, + }, + checkpoint_status=404, + ) + + code, out = _run() + + assert code == 0 + assert "No checkpoint history for this job" in out + # The notice replaces "Nothing to report" rather than sitting next to it: + # the slowdown half never ran, so a clean bill of health is not something + # this output can give. + assert "Nothing to report" not in out + + +def test_a_cluster_with_the_monitor_off_is_reported_not_passed_over(engine): + # The shape that actually occurs: with the monitor service off, or with + # nothing recorded for the job yet, CheckpointMonitorRestService answers + # 200 with only {"jobId": ...} and no `pipelines`. That is not an + # exception, so an except-only notice would never print and the output + # would end on "Nothing to report" with the slowdown half never run. + engine.script = _routed( + { + "jobId": "852", + "jobStatus": "RUNNING", + "diagnostics": {"generatedAt": _NOW, "stateTimestamps": {"RUNNING": _NOW - 5_000}}, + }, + checkpoints={"jobId": "852"}, + ) + + code, out = _run() + + assert code == 0 + assert "No checkpoint history for this job" in out + assert "Nothing to report" not in out + + +def test_an_unparsable_checkpoint_response_does_not_break_the_diagnosis(engine): + engine.script = _routed( + { + "jobId": "852", + "jobStatus": "FAILED", + "errorMsg": "ErrorCode:[JDBC-05], ErrorDescription:[Connection failed]", + }, + checkpoint_status=200, + ) + + code, out = _run() + + assert code == 1 + assert "JDBC-05" in out + + +def test_a_checkpoint_error_outranks_a_job_level_info_line(engine): + # The two rule sets are merged and sorted together. Sorting them + # separately would print "Running for 72h" above a stalled checkpoint. + now = 1_760_000_000_000 + engine.script = _routed( + { + "jobId": "852", + "jobStatus": "RUNNING", + "diagnostics": {"stateTimestamps": {"RUNNING": now - 72 * 3600 * 1000}}, + }, + { + "jobId": "852", + "pipelines": [ + { + "pipelineId": 1, + "inProgress": [ + { + "checkpointId": 91, + "triggerTimestamp": now - 1800 * 1000, + "acknowledged": 2, + "total": 6, + } + ], + } + ], + }, + ) + + code, out = _run() + + assert code == 0 + lines = [line for line in out.splitlines() if "[error]" in line or "[info]" in line] + assert "[error]" in lines[0] + assert "2 of 6" in out + + +def test_the_severity_label_is_printed_and_not_eaten_as_markup(engine): + # "error" is a style name in THEME, so rich would treat the "[error]" + # prefix as a style tag and drop it, leaving severity carried by colour + # alone -- lost in a pipe, in CI, or under NO_COLOR. + engine.script = _routed( + { + "jobId": "852", + "jobStatus": "FAILED", + "errorMsg": "ErrorCode:[JDBC-05], ErrorDescription:[Connection failed]", + }, + checkpoint_status=404, + ) + console = Console(theme=cli_module.THEME, width=200, record=True, no_color=True) + + code = cli_module.run_diagnose("852", console) + out = console.export_text() + + assert code == 1 + assert "[error]" in out + + +def test_brackets_in_an_engine_message_cannot_corrupt_the_output(engine): + # The checkpoint failure reason is engine text passed straight through to + # the terminal, and a Java message routinely contains bracketed text. The + # error headline would not do here: it keeps only the code and + # description, so brackets never reach the output through that path. + engine.script = _routed( + {"jobId": "852", "jobStatus": "RUNNING"}, + { + "jobId": "852", + "pipelines": [ + { + "pipelineId": 1, + "counts": {"triggered": 10, "completed": 6, "failed": 4}, + "latestFailed": { + "failureReason": "expired at Table[/bold] row[nope] idx[0]" + }, + } + ], + }, + ) + + code, out = _run() + + # Rich wraps the line, so compare with whitespace normalised. + flat = " ".join(out.split()) + assert code == 0 + assert "Table[/bold] row[nope] idx[0]" in flat diff --git a/seatunnel-cli/tests/test_diagnose_job.py b/seatunnel-cli/tests/test_diagnose_job.py new file mode 100644 index 0000000000..53dcee7338 --- /dev/null +++ b/seatunnel-cli/tests/test_diagnose_job.py @@ -0,0 +1,683 @@ +# +# 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 rule-based job diagnosis. + +The job-info payloads here are shaped the way the engine actually sends them, +which is not the way one would guess: every metric leaf is a *string* because +`BaseService.metricsToJsonObject` calls `toString()` on it, the job-wide row +counts are `SourceReceivedCount`/`SinkWriteCount` while the `Table`-prefixed +names are objects keyed by table, and `stateTimestamps` is a state-name map +rather than an ordered list. Those three details are the ones a diagnosis +built on assumptions would get wrong, so they are pinned here. +""" + +from seatunnel_cli.diagnostics.jobs import ( + CRASH_LOOP_RECENT_SECONDS, + CRASH_LOOP_RESTORES, + STUCK_SECONDS, + diagnose_job, +) + +NOW = 1_760_000_000_000 # fixed clock, so the stuck rule is not time-dependent + +# Long enough for the progress rules to apply at all: a zero row count on a +# job that started seconds ago is the expected reading, not a symptom. +RUNNING_LONG_ENOUGH = {"RUNNING": NOW - (STUCK_SECONDS + 60) * 1000} + + +def _rules(findings): + return [f.rule for f in findings] + + +def _by_rule(findings, rule): + return next(f for f in findings if f.rule == rule) + + +def test_failed_job_leads_with_the_parsed_error_code(): + findings = diagnose_job( + { + "jobStatus": "FAILED", + "errorMsg": ( + "java.util.concurrent.CompletionException: wrapper\n" + "Caused by: org.apache.seatunnel.common.exception.SeaTunnelRuntimeException: " + "ErrorCode:[JDBC-05], ErrorDescription:[Connection failed]\n" + "Caused by: java.sql.SQLException: Access denied for user 'bench'\n" + ), + }, + now_ms=NOW, + ) + + assert "job_failed" in _rules(findings) + assert "JDBC-05" in _by_rule(findings, "job_failed").detail + + +def test_failed_with_no_error_message_says_so_instead_of_staying_silent(): + # Silence here would be the worst outcome: the user would think the tool + # had nothing to say about a job that definitely broke. + findings = diagnose_job({"jobStatus": "FAILED", "errorMsg": " "}, now_ms=NOW) + + assert _rules(findings) == ["failed_without_error"] + + +def test_repeated_restores_with_a_young_attempt_are_reported_as_a_crash_loop(): + findings = diagnose_job( + { + "jobStatus": "RUNNING", + "diagnostics": { + "pipelines": [ + { + "pipelineId": 1, + "restoreCount": 4, + "maxRestoreCount": 10, + "pipelineStatus": "RUNNING", + # Restored 30s ago: the loop is happening now. + "stateTimestamps": {"RUNNING": NOW - 30_000}, + }, + ], + }, + }, + now_ms=NOW, + ) + + loop = _by_rule(findings, "pipeline_crash_loop") + assert loop.severity == "warning" + assert "restoreCount=4/10" in loop.evidence + assert "30s old" in loop.detail + + +def test_old_restores_on_a_steady_pipeline_are_history_not_a_crash_loop(): + # `restoreCount` counts restores since submission, so a streaming job that + # recovered from two transient faults weeks ago carries the same count as + # one restarting every minute. Reporting the first as "making no progress" + # is simply false, and it is the reading a long-lived job most often has. + findings = diagnose_job( + { + "jobStatus": "RUNNING", + "diagnostics": { + "pipelines": [ + { + "pipelineId": 1, + "restoreCount": 2, + "maxRestoreCount": 10, + "pipelineStatus": "RUNNING", + "stateTimestamps": {"RUNNING": NOW - 14 * 86400 * 1000}, + }, + ], + }, + }, + now_ms=NOW, + ) + + assert "pipeline_crash_loop" not in _rules(findings) + note = _by_rule(findings, "pipeline_restored_before") + assert note.severity == "info" + assert "has been running since" in note.detail + assert "no progress" not in note.detail + + +def test_a_pipeline_not_running_with_restores_behind_it_is_a_crash_loop(): + # Mid-restart is the one moment the loop is directly observable. + findings = diagnose_job( + { + "jobStatus": "RUNNING", + "diagnostics": { + "pipelines": [ + { + "pipelineId": 1, + "restoreCount": 5, + "maxRestoreCount": 10, + "pipelineStatus": "SCHEDULED", + "stateTimestamps": {"SCHEDULED": NOW - 1000}, + }, + ], + }, + }, + now_ms=NOW, + ) + + loop = _by_rule(findings, "pipeline_crash_loop") + assert loop.severity == "warning" + assert "SCHEDULED rather than RUNNING" in loop.detail + + +def test_restores_with_no_timing_evidence_are_reported_as_a_bare_count(): + # An older engine, or a master that has just taken over, can send the + # counter without pipeline state timestamps. Without them there is no + # evidence of a loop, so the count is reported as a count. + findings = diagnose_job( + { + "jobStatus": "RUNNING", + "diagnostics": { + "pipelines": [{"pipelineId": 1, "restoreCount": 4, "maxRestoreCount": 10}], + }, + }, + now_ms=NOW, + ) + + note = _by_rule(findings, "pipeline_restored_before") + assert note.severity == "info" + assert "not visible in this response" in note.detail + + +def _pipeline(status, restores=3, limit=10, **extra): + p = { + "pipelineId": 1, + "restoreCount": restores, + "maxRestoreCount": limit, + "pipelineStatus": status, + "stateTimestamps": {status: NOW - 1000}, + } + p.update(extra) + return p + + +def test_finished_and_cancelled_pipelines_are_not_called_restarting(): + # A restorable pipeline is in an end state for an instant only: + # prepareRestorePipeline increments the counter and immediately resets + # back toward CREATED. So a pipeline *observed* in an end state is over, + # and a restore count on it is history. In a multi-pipeline job this is + # the normal reading for a pipeline that finished after two restores. + for status in ("FINISHED", "CANCELED"): + findings = diagnose_job( + {"jobStatus": "RUNNING", "diagnostics": {"pipelines": [_pipeline(status)]}}, + now_ms=NOW, + ) + + assert findings == [], status + + +def test_an_end_state_pipeline_at_its_restore_limit_is_not_an_error_about_the_future(): + # "The next failure ends the job" is meaningless for a pipeline that has + # already finished, and it was previously reported as an error. + for status in ("FINISHED", "CANCELED"): + findings = diagnose_job( + { + "jobStatus": "RUNNING", + "diagnostics": {"pipelines": [_pipeline(status, restores=10, limit=10)]}, + }, + now_ms=NOW, + ) + + assert findings == [], status + + +def test_a_failed_pipeline_is_reported_in_the_past_tense_only_when_exhausted(): + exhausted = diagnose_job( + { + "jobStatus": "FAILING", + "diagnostics": {"pipelines": [_pipeline("FAILED", restores=10, limit=10)]}, + }, + now_ms=NOW, + ) + spent = _by_rule(exhausted, "pipeline_restore_exhausted") + assert "used all 10 of its restores and then failed" in spent.detail + assert "next failure" not in spent.detail + + # Below the limit a failure was not restorable; the job-level failure + # finding covers it, so there is nothing to add here. + not_exhausted = diagnose_job( + { + "jobStatus": "FAILING", + "diagnostics": {"pipelines": [_pipeline("FAILED", restores=3, limit=10)]}, + }, + now_ms=NOW, + ) + assert not_exhausted == [] + + +def test_a_tearing_down_pipeline_reports_restores_as_history(): + for status in ("CANCELING", "FAILING"): + findings = diagnose_job( + {"jobStatus": "CANCELING", "diagnostics": {"pipelines": [_pipeline(status)]}}, + now_ms=NOW, + ) + + note = _by_rule(findings, "pipeline_restored_before") + assert note.severity == "info", status + assert "no progress" not in note.detail + + +def test_a_finished_pipeline_next_to_a_running_one_is_not_reported(): + # The multi-pipeline case the end-state bug showed up in: one pipeline + # finishing must not make the whole job look like it is crash-looping. + findings = diagnose_job( + { + "jobStatus": "RUNNING", + "diagnostics": { + "pipelines": [ + _pipeline("FINISHED", restores=4), + { + "pipelineId": 2, + "restoreCount": 4, + "maxRestoreCount": 10, + "pipelineStatus": "RUNNING", + "stateTimestamps": {"RUNNING": NOW - 30_000}, + }, + ] + }, + }, + now_ms=NOW, + ) + + rules = _rules(findings) + assert rules.count("pipeline_crash_loop") == 1 + assert "Pipeline 2" in _by_rule(findings, "pipeline_crash_loop").detail + + +def test_a_pipeline_between_attempts_is_reported_as_such(): + findings = diagnose_job( + {"jobStatus": "RUNNING", "diagnostics": {"pipelines": [_pipeline("DEPLOYING")]}}, + now_ms=NOW, + ) + + loop = _by_rule(findings, "pipeline_crash_loop") + assert loop.severity == "warning" + assert "between attempts" in loop.detail + + +def test_a_pipeline_restored_just_under_the_recency_window_still_warns(): + findings = diagnose_job( + { + "jobStatus": "RUNNING", + "diagnostics": { + "pipelines": [ + { + "pipelineId": 1, + "restoreCount": 3, + "maxRestoreCount": 10, + "pipelineStatus": "RUNNING", + "stateTimestamps": { + "RUNNING": NOW - (CRASH_LOOP_RECENT_SECONDS - 1) * 1000 + }, + }, + ], + }, + }, + now_ms=NOW, + ) + + assert _by_rule(findings, "pipeline_crash_loop").severity == "warning" + + +def test_a_pipeline_at_its_restore_limit_is_an_error_not_a_warning(): + findings = diagnose_job( + { + "jobStatus": "RUNNING", + "diagnostics": { + "pipelines": [ + {"pipelineId": 2, "restoreCount": 3, "maxRestoreCount": 3}, + ], + }, + }, + now_ms=NOW, + ) + + exhausted = _by_rule(findings, "pipeline_restore_exhausted") + assert exhausted.severity == "error" + + +def test_a_single_restore_is_not_flagged(): + # One restore is ordinary recovery from a transient fault; flagging it + # would make healthy clusters look sick. + findings = diagnose_job( + { + "jobStatus": "RUNNING", + "diagnostics": { + "pipelines": [{"pipelineId": 1, "restoreCount": CRASH_LOOP_RESTORES - 1}], + }, + }, + now_ms=NOW, + ) + + assert findings == [] + + +def test_long_scheduled_state_is_reported_as_stuck(): + entered = NOW - (STUCK_SECONDS + 60) * 1000 + findings = diagnose_job( + { + "jobStatus": "SCHEDULED", + "diagnostics": {"stateTimestamps": {"CREATED": entered - 5000, "SCHEDULED": entered}}, + }, + now_ms=NOW, + ) + + stuck = _by_rule(findings, "stuck_before_running") + assert "SCHEDULED for 3m00s" in stuck.detail + assert "slots" in stuck.detail + + +def test_the_duration_comes_from_the_current_state_not_an_older_one(): + # A restored job re-enters an earlier state, so the map holds entries for + # several states. Reading the wrong one -- the last key, or the oldest -- + # reports a duration off by days. + findings = diagnose_job( + { + "jobStatus": "SCHEDULED", + "diagnostics": { + "stateTimestamps": { + "SCHEDULED": NOW - (STUCK_SECONDS + 1) * 1000, + "CREATED": NOW - 10 * 24 * 3600 * 1000, + } + }, + }, + now_ms=NOW, + ) + + assert "2m01s" in _by_rule(findings, "stuck_before_running").detail + + +def test_a_state_with_no_entry_of_its_own_falls_back_to_the_newest_entry(): + # PENDING has no timestamp here, so the best available answer is the most + # recent transition the engine did record. + findings = diagnose_job( + { + "jobStatus": "PENDING", + "diagnostics": { + "stateTimestamps": { + "CREATED": NOW - 10 * 24 * 3600 * 1000, + "INITIALIZING": NOW - (STUCK_SECONDS + 1) * 1000, + } + }, + }, + now_ms=NOW, + ) + + assert "2m01s" in _by_rule(findings, "stuck_before_running").detail + + +def test_a_job_stuck_tearing_down_is_not_blamed_on_cluster_slots(): + # CANCELING forever is a task ignoring the stop request, so pointing the + # user at free slots would send them to the wrong place entirely. + findings = diagnose_job( + { + "jobStatus": "CANCELING", + "diagnostics": { + "stateTimestamps": {"CANCELING": NOW - (STUCK_SECONDS + 1) * 1000} + }, + }, + now_ms=NOW, + ) + + stuck = _by_rule(findings, "stuck_tearing_down") + assert "slots" not in stuck.detail + assert "stop request" in stuck.detail + + +def test_a_long_savepoint_is_not_described_as_an_unresponsive_task(): + # A savepoint writes the whole job state, so minutes can be exactly what + # the operator asked for. "Not responding to the stop request" would send + # someone hunting a hung task over a job doing its job. + findings = diagnose_job( + { + "jobStatus": "DOING_SAVEPOINT", + "diagnostics": { + "stateTimestamps": {"DOING_SAVEPOINT": NOW - (STUCK_SECONDS + 1) * 1000} + }, + }, + now_ms=NOW, + ) + + savepoint = _by_rule(findings, "savepoint_in_progress") + assert "stop request" not in savepoint.detail + assert "can be normal" in savepoint.detail + + +def test_the_masters_generated_at_is_preferred_over_the_local_clock(): + # Every timestamp compared here was written by the master, so mixing in a + # local clock that is minutes off invents stuck jobs (or hides them). With + # no `now_ms` injected, `generatedAt` is the right "now". + job_info = { + "jobStatus": "SCHEDULED", + "diagnostics": { + "generatedAt": NOW, + "stateTimestamps": {"SCHEDULED": NOW - (STUCK_SECONDS + 60) * 1000}, + }, + } + + assert "3m00s" in _by_rule(diagnose_job(job_info), "stuck_before_running").detail + + +def test_a_missing_generated_at_falls_back_to_the_local_clock(): + # An older engine sends no diagnostics block at all; the rules that do not + # need it must still work, so the fallback has to stay. + job_info = { + "jobStatus": "SCHEDULED", + "diagnostics": {"stateTimestamps": {"SCHEDULED": 1_000}}, + } + + assert "stuck_before_running" in _rules(diagnose_job(job_info)) + + +def test_a_freshly_scheduled_job_is_not_called_stuck(): + findings = diagnose_job( + { + "jobStatus": "SCHEDULED", + "diagnostics": {"stateTimestamps": {"SCHEDULED": NOW - 3000}}, + }, + now_ms=NOW, + ) + + assert findings == [] + + +def test_a_long_running_job_is_information_not_a_warning(): + findings = diagnose_job( + { + "jobStatus": "RUNNING", + "diagnostics": {"stateTimestamps": {"RUNNING": NOW - 7200 * 1000}}, + }, + now_ms=NOW, + ) + + running = _by_rule(findings, "running_duration") + assert running.severity == "info" + assert "2h00m" in running.detail + + +def test_a_finished_job_is_not_measured_for_stuckness(): + # FINISHED is terminal, so the age of the state is just how long ago the + # job ended and says nothing about health. + findings = diagnose_job( + { + "jobStatus": "FINISHED", + "diagnostics": {"stateTimestamps": {"FINISHED": NOW - 30 * 86400 * 1000}}, + }, + now_ms=NOW, + ) + + assert findings == [] + + +def test_zero_rows_read_is_reported_from_string_valued_metrics(): + # The engine stringifies every metric leaf, so an int comparison on the + # raw value would never match. + findings = diagnose_job( + { + "jobStatus": "RUNNING", + "metrics": {"SourceReceivedCount": "0", "SinkWriteCount": "0"}, + "diagnostics": {"stateTimestamps": RUNNING_LONG_ENOUGH}, + }, + now_ms=NOW, + ) + + assert _by_rule(findings, "no_rows_read").severity == "warning" + + +def test_a_freshly_running_job_that_has_read_nothing_yet_is_not_flagged(): + # A zero counter one second into RUNNING is what every job looks like + # before its first batch arrives. Warning here would mean every job is + # reported as broken for its first two minutes. + findings = diagnose_job( + { + "jobStatus": "RUNNING", + "metrics": {"SourceReceivedCount": "0", "SinkWriteCount": "0"}, + "diagnostics": {"stateTimestamps": {"RUNNING": NOW - 1000}}, + }, + now_ms=NOW, + ) + + assert findings == [] + + +def test_progress_rules_stay_silent_without_a_running_timestamp(): + # No RUNNING entry means the job's uptime is unknown, and the difference + # between "read nothing for an hour" and "started a second ago" is the + # whole finding. + findings = diagnose_job( + { + "jobStatus": "RUNNING", + "metrics": {"SourceReceivedCount": "0", "SinkWriteCount": "0"}, + "diagnostics": {"stateTimestamps": {"CREATED": NOW - 86400 * 1000}}, + }, + now_ms=NOW, + ) + + assert "no_rows_read" not in _rules(findings) + + +def test_an_idle_streaming_source_is_a_note_not_a_warning(): + # A quiet topic or a CDC job waiting for its first change event reads + # nothing and is perfectly healthy, so for a streaming job this cannot be + # asserted as a fault. + findings = diagnose_job( + { + "jobStatus": "RUNNING", + "envOptions": {"job.mode": "STREAMING"}, + "metrics": {"SourceReceivedCount": "0", "SinkWriteCount": "0"}, + "diagnostics": {"stateTimestamps": RUNNING_LONG_ENOUGH}, + }, + now_ms=NOW, + ) + + idle = _by_rule(findings, "no_rows_read") + assert idle.severity == "info" + assert "normal" in idle.detail + + +def test_a_batch_source_reading_nothing_is_still_a_warning(): + findings = diagnose_job( + { + "jobStatus": "RUNNING", + "envOptions": {"job.mode": "BATCH"}, + "metrics": {"SourceReceivedCount": "0", "SinkWriteCount": "0"}, + "diagnostics": {"stateTimestamps": RUNNING_LONG_ENOUGH}, + }, + now_ms=NOW, + ) + + assert _by_rule(findings, "no_rows_read").severity == "warning" + + +def test_rows_read_but_none_written_points_between_source_and_sink(): + findings = diagnose_job( + { + "jobStatus": "RUNNING", + "metrics": {"SourceReceivedCount": "1500", "SinkWriteCount": "0"}, + "diagnostics": {"stateTimestamps": RUNNING_LONG_ENOUGH}, + }, + now_ms=NOW, + ) + + dropped = _by_rule(findings, "rows_read_none_written") + assert "1500" in dropped.detail + + +def test_healthy_throughput_produces_no_progress_finding(): + findings = diagnose_job( + { + "jobStatus": "RUNNING", + "metrics": {"SourceReceivedCount": "1500", "SinkWriteCount": "1500"}, + "diagnostics": {"stateTimestamps": RUNNING_LONG_ENOUGH}, + }, + now_ms=NOW, + ) + + assert _rules(findings) == ["running_duration"] + + +def test_table_scoped_metrics_are_not_mistaken_for_scalars(): + # "TableSourceReceivedCount" is an object keyed by table name. Reading it + # as a count would make `int(...)` fail or, worse, coerce to something + # meaningless -- either way a false "no rows read" on a healthy job. + findings = diagnose_job( + { + "jobStatus": "RUNNING", + "metrics": { + "TableSourceReceivedCount": {"db.public.users": "1500"}, + "TableSinkWriteCount": {"db.public.users": "1500"}, + }, + "diagnostics": {"stateTimestamps": RUNNING_LONG_ENOUGH}, + }, + now_ms=NOW, + ) + + assert _rules(findings) == ["running_duration"] + + +def test_findings_are_ordered_worst_first(): + entered = NOW - (STUCK_SECONDS + 10) * 1000 + findings = diagnose_job( + { + "jobStatus": "FAILED", + "errorMsg": "ErrorCode:[JDBC-05], ErrorDescription:[Connection failed]", + "diagnostics": { + "pipelines": [{"pipelineId": 1, "restoreCount": 2, "maxRestoreCount": 3}], + "stateTimestamps": {"FAILED": entered}, + }, + }, + now_ms=NOW, + ) + + severities = [f.severity for f in findings] + assert severities == sorted(severities, key=["error", "warning", "info"].index) + assert findings[0].rule == "job_failed" + + +def test_malformed_payloads_yield_no_findings_rather_than_raising(): + # Everything here is a shape the engine should never send, but a crash + # while diagnosing is strictly worse than saying nothing. + for payload in ( + None, + [], + "FAILED", + {}, + {"jobStatus": None}, + {"jobStatus": "RUNNING", "diagnostics": "unavailable"}, + {"jobStatus": "RUNNING", "diagnostics": {"pipelines": "none"}}, + {"jobStatus": "RUNNING", "diagnostics": {"pipelines": [None, 7]}}, + {"jobStatus": "RUNNING", "diagnostics": {"stateTimestamps": []}}, + {"jobStatus": "RUNNING", "diagnostics": {"stateTimestamps": {"RUNNING": "soon"}}}, + {"jobStatus": "RUNNING", "metrics": None}, + {"jobStatus": "RUNNING", "metrics": {"SourceReceivedCount": "n/a"}}, + ): + assert diagnose_job(payload, now_ms=NOW) == [] + + +def test_restore_count_of_true_is_not_counted_as_one(): + # bool is a subclass of int in Python, so an unguarded int() would turn a + # stray `true` into a restore count. + findings = diagnose_job( + { + "jobStatus": "RUNNING", + "diagnostics": {"pipelines": [{"pipelineId": 1, "restoreCount": True}]}, + }, + now_ms=NOW, + ) + + assert findings == []
