From b3564737f71c52e9583b14789c0008d74d30f18e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 17:33:36 +0800 Subject: [PATCH] refactor: replace yarn CLI shell-out with YARN REST API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit yarn_client.py no longer invokes the 'yarn' binary via subprocess; it uses httpx against /ws/v1/cluster/apps/* endpoints. This means the runtime image no longer needs the Hadoop client installation — the only YARN-side dependency left in the container is the config dir consumed by spark-submit itself. New model field: - Job.yarn_rm_url: str | None - PendingSubmission.yarn_rm_url: str | None (snapshotted at prepare) prepare_submit_job snapshots Connection.yarn_rm_url into the pending record (consistent with the existing master/deploy_mode/spark_conf snapshot pattern); confirm_submit_job copies it onto the Job so status/logs/kill can use it without re-looking-up the connection. Resolution order for the RM URL at runtime: 1. Job.yarn_rm_url (preferred — survives connection edits/deletes) 2. Connection.yarn_rm_url fallback (if a future tool is added that doesn't go through a Job) 3. YARN_RESOURCE_MANAGER_URL env var Errors: - YarnConfigError (HTTP 4xx semantics) when URL is missing/malformed - YarnError for HTTP 4xx/5xx from the RM, network failures, missing state field, or unparseable log responses 10 new tests in test_yarn_client.py cover the REST surface: success, 404, 5xx, missing state field, env-var fallback, malformed URL, log 404 with log-aggregation hint, kill PUT body shape, and httpx connection-error wrapping. --- spark_executor/core/yarn_client.py | 136 +++++++++++++++++---------- spark_executor/models.py | 2 + spark_executor/tools/kill.py | 2 +- spark_executor/tools/logs.py | 2 +- spark_executor/tools/status.py | 2 +- spark_executor/tools/submit.py | 2 + tests/unit/test_kill_tool.py | 3 +- tests/unit/test_logs_tool.py | 1 + tests/unit/test_models.py | 34 +++++++ tests/unit/test_status_tool.py | 5 +- tests/unit/test_submit_tool.py | 11 ++- tests/unit/test_yarn_client.py | 143 +++++++++++++++++++++-------- 12 files changed, 252 insertions(+), 91 deletions(-) diff --git a/spark_executor/core/yarn_client.py b/spark_executor/core/yarn_client.py index 89ab071..afbae3a 100644 --- a/spark_executor/core/yarn_client.py +++ b/spark_executor/core/yarn_client.py @@ -2,69 +2,109 @@ """ @Time :2026/6/24 @Author :tao.chen + +YARN ResourceManager REST API client. Replaces the previous `yarn` CLI shell-out +so the runtime image does not need a Hadoop client installation — the +`httpx` library already in pyproject.toml is enough. + +Endpoints used (YARN 2.6+): + GET /ws/v1/cluster/apps/{appid} -> app status + state + GET /ws/v1/cluster/apps/{appid}/aggregated-logs -> aggregated container logs + PUT /ws/v1/cluster/apps/{appid}/state -> kill an app (body: {"state":"KILLED"}) + +The ResourceManager URL is passed in per call (snapshotted on the Job at +confirm_submit_job time) and falls back to the YARN_RESOURCE_MANAGER_URL env +var if unset. This matches the pattern the original Connection.yarn_rm_url +field was designed for, but no longer requires the `yarn` CLI to interpret it. """ -import re -import subprocess +import json +import os + +import httpx from common.logging import logger class YarnError(Exception): - """Raised when a yarn CLI invocation fails.""" + """Raised when a YARN REST API call fails.""" -_STATE_RE = re.compile(r"State\s*:\s*(\S+)") +class YarnConfigError(YarnError): + """Raised when the YARN ResourceManager URL is missing or malformed.""" -def _run(cmd: list[str]) -> "subprocess.CompletedProcess[str]": - logger.debug(f"yarn _run exec: {cmd}") - result = subprocess.run(cmd, capture_output=True, text=True, errors="replace") +def _base_url(yarn_rm_url: str | None) -> str: + """Resolve and validate the RM URL. Raises YarnConfigError if unusable.""" + url = yarn_rm_url or os.environ.get("YARN_RESOURCE_MANAGER_URL") + if not url: + raise YarnConfigError( + "YARN ResourceManager URL is not configured. " + "Set Connection.yarn_rm_url when saving the connection, " + "or set the YARN_RESOURCE_MANAGER_URL environment variable." + ) + base = url.rstrip("/") + if not base.startswith(("http://", "https://")): + raise YarnConfigError( + f"YARN ResourceManager URL must start with http:// or https://: {url!r}" + ) + return base + + +def _request(method: str, url: str, *, json_body: dict | None = None, + timeout: float = 30.0) -> httpx.Response: + logger.debug(f"YARN {method} {url}" + (f" body={json_body}" if json_body else "")) + try: + resp = httpx.request(method, url, json=json_body, timeout=timeout) + except httpx.HTTPError as exc: + logger.error(f"YARN {method} {url} failed: {exc}") + raise YarnError(f"YARN connection failed: {exc}") from exc logger.debug( - f"yarn _run done rc={result.returncode} " - f"stdout_len={len(result.stdout)} stderr_len={len(result.stderr)}" + f"YARN {method} {url} -> {resp.status_code} " + f"({len(resp.content)} bytes)" ) - return result + return resp -def get_application_status(application_id: str) -> tuple[str, str]: - proc = _run(["yarn", "application", "-status", application_id]) - if proc.returncode != 0: - logger.error( - f"yarn application -status failed (rc={proc.returncode}) for " - f"{application_id}: {proc.stderr[:500]}" - ) +def get_application_status(application_id: str, yarn_rm_url: str | None) -> tuple[str, str]: + """Return (state, raw_json_text) for an application, or raise YarnError.""" + url = f"{_base_url(yarn_rm_url)}/ws/v1/cluster/apps/{application_id}" + resp = _request("GET", url) + if resp.status_code == 404: + raise YarnError(f"YARN application {application_id!r} not found") + if resp.status_code >= 400: + logger.error(f"YARN GET {url} -> {resp.status_code}: {resp.text[:500]}") + raise YarnError(f"YARN GET returned HTTP {resp.status_code}") + data = resp.json() + app = data.get("app", {}) + state = app.get("state") + if not state: + raise YarnError(f"Could not parse YARN state from response: {data!r}") + logger.info(f"YARN status {application_id} -> {state}") + return state, json.dumps(data, indent=2) + + +def get_application_logs(application_id: str, yarn_rm_url: str | None) -> str: + """Return aggregated container logs for an application as text.""" + url = f"{_base_url(yarn_rm_url)}/ws/v1/cluster/apps/{application_id}/aggregated-logs" + resp = _request("GET", url, timeout=60.0) + if resp.status_code == 404: raise YarnError( - f"yarn application -status failed (rc={proc.returncode}): {proc.stderr}" + f"YARN aggregated logs not available for {application_id!r}. " + f"The application may not be in FINISHED state, or " + f"yarn.log-aggregation-enable is false on the cluster." ) - match = _STATE_RE.search(proc.stdout) - if not match: - raise YarnError(f"Could not parse YARN state from output: {proc.stdout!r}") - state = match.group(1) - logger.info(f"yarn status {application_id} -> {state}") - return state, proc.stdout + if resp.status_code >= 400: + logger.error(f"YARN GET {url} -> {resp.status_code}: {resp.text[:500]}") + raise YarnError(f"YARN GET logs returned HTTP {resp.status_code}") + logger.info(f"YARN logs {application_id} -> {len(resp.text)} chars") + return resp.text -def get_application_logs(application_id: str) -> str: - proc = _run(["yarn", "logs", "-applicationId", application_id]) - if proc.returncode != 0: - logger.error( - f"yarn logs failed (rc={proc.returncode}) for {application_id}: {proc.stderr[:500]}" - ) - raise YarnError( - f"yarn logs failed (rc={proc.returncode}): {proc.stderr}" - ) - logger.info(f"yarn logs {application_id} -> {len(proc.stdout)} chars") - return proc.stdout - - -def kill_application(application_id: str) -> None: - proc = _run(["yarn", "application", "-kill", application_id]) - if proc.returncode != 0: - logger.error( - f"yarn application -kill failed (rc={proc.returncode}) for " - f"{application_id}: {proc.stderr[:500]}" - ) - raise YarnError( - f"yarn application -kill failed (rc={proc.returncode}): {proc.stderr}" - ) - logger.info(f"yarn kill {application_id} -> ok") +def kill_application(application_id: str, yarn_rm_url: str | None) -> None: + """PUT state=KILLED to /ws/v1/cluster/apps/{appid}/state.""" + url = f"{_base_url(yarn_rm_url)}/ws/v1/cluster/apps/{application_id}/state" + resp = _request("PUT", url, json_body={"state": "KILLED"}) + if resp.status_code >= 400: + logger.error(f"YARN PUT {url} -> {resp.status_code}: {resp.text[:500]}") + raise YarnError(f"YARN kill returned HTTP {resp.status_code}: {resp.text}") + logger.info(f"YARN kill {application_id} -> ok") diff --git a/spark_executor/models.py b/spark_executor/models.py index 17ba1cb..704f9de 100644 --- a/spark_executor/models.py +++ b/spark_executor/models.py @@ -15,6 +15,7 @@ class Job(BaseModel): queue: str submit_time: datetime connection: str + yarn_rm_url: str | None = None class JobStatus(BaseModel): @@ -42,6 +43,7 @@ class PendingSubmission(BaseModel): connection: str master: str deploy_mode: str + yarn_rm_url: str | None = None script_path: str queue: str executor_memory: str diff --git a/spark_executor/tools/kill.py b/spark_executor/tools/kill.py index 8d9409e..a35de04 100644 --- a/spark_executor/tools/kill.py +++ b/spark_executor/tools/kill.py @@ -15,7 +15,7 @@ def kill_job(job_id: str) -> dict[str, str]: job = store.get(job_id) if job is None: raise KeyError(f"Unknown job_id: {job_id}") - kill_application(job.application_id) + kill_application(job.application_id, job.yarn_rm_url) logger.info(f"kill_job ok job_id={job_id} application_id={job.application_id}") return { "job_id": job_id, diff --git a/spark_executor/tools/logs.py b/spark_executor/tools/logs.py index 2834878..f06dea6 100644 --- a/spark_executor/tools/logs.py +++ b/spark_executor/tools/logs.py @@ -15,7 +15,7 @@ def get_job_logs(job_id: str, tail_chars: int = 5000) -> str: job = store.get(job_id) if job is None: raise KeyError(f"Unknown job_id: {job_id}") - full = get_application_logs(job.application_id) + full = get_application_logs(job.application_id, job.yarn_rm_url) tailed = full[-tail_chars:] if len(full) > tail_chars else full logger.info( f"get_job_logs ok job_id={job_id} application_id={job.application_id} " diff --git a/spark_executor/tools/status.py b/spark_executor/tools/status.py index 17b2f86..2323765 100644 --- a/spark_executor/tools/status.py +++ b/spark_executor/tools/status.py @@ -16,6 +16,6 @@ def get_job_status(job_id: str) -> JobStatus: job = store.get(job_id) if job is None: raise KeyError(f"Unknown job_id: {job_id}") - state, raw = get_application_status(job.application_id) + state, raw = get_application_status(job.application_id, job.yarn_rm_url) logger.info(f"get_job_status ok job_id={job_id} application_id={job.application_id} state={state}") return JobStatus(application_id=job.application_id, state=state, raw=raw) diff --git a/spark_executor/tools/submit.py b/spark_executor/tools/submit.py index db72311..1518774 100644 --- a/spark_executor/tools/submit.py +++ b/spark_executor/tools/submit.py @@ -49,6 +49,7 @@ def prepare_submit_job( connection=connection, master=conn.master, deploy_mode=conn.deploy_mode, + yarn_rm_url=conn.yarn_rm_url, script_path=script_path, queue=queue, executor_memory=executor_memory, @@ -119,6 +120,7 @@ def confirm_submit_job(*, pending_id: str) -> SubmitResult: queue=pending.queue, submit_time=datetime.utcnow(), connection=pending.connection, + yarn_rm_url=pending.yarn_rm_url, ) ) diff --git a/tests/unit/test_kill_tool.py b/tests/unit/test_kill_tool.py index bf7e490..bdc851d 100644 --- a/tests/unit/test_kill_tool.py +++ b/tests/unit/test_kill_tool.py @@ -17,11 +17,12 @@ def test_kill_job_calls_yarn_kill(): queue="default", submit_time=datetime(2026, 6, 24), connection="prod", + yarn_rm_url="http://rm:8088", ) ) with patch("spark_executor.tools.kill.kill_application") as m: result = kill.kill_job("abc") - m.assert_called_once_with("application_1") + m.assert_called_once_with("application_1", "http://rm:8088") assert result == { "job_id": "abc", "application_id": "application_1", diff --git a/tests/unit/test_logs_tool.py b/tests/unit/test_logs_tool.py index 3c349de..1b518a9 100644 --- a/tests/unit/test_logs_tool.py +++ b/tests/unit/test_logs_tool.py @@ -17,6 +17,7 @@ def _seed(job_id="abc", app_id="application_1"): queue="default", submit_time=datetime(2026, 6, 24), connection="prod", + yarn_rm_url="http://rm:8088", ) ) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 62e6e75..d16dfb9 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -11,11 +11,25 @@ def test_job_roundtrip(): queue="default", submit_time=datetime(2026, 6, 24, 10, 0, 0), connection="prod-yarn", + yarn_rm_url="http://rm:8088", ) dumped = job.model_dump() assert dumped["job_id"] == "abc123" assert dumped["application_id"] == "application_17400000001" assert dumped["connection"] == "prod-yarn" + assert dumped["yarn_rm_url"] == "http://rm:8088" + + +def test_job_yarn_rm_url_optional(): + job = Job( + job_id="j1", + application_id="application_1", + script_path="/tmp/x.py", + queue="default", + submit_time=datetime(2026, 6, 24), + connection="dev", + ) + assert job.yarn_rm_url is None def test_job_status_default_raw(): @@ -63,6 +77,26 @@ def test_pending_submission_defaults_to_pending_status(): assert p.error is None assert p.job_id is None assert p.application_id is None + assert p.yarn_rm_url is None # default for connections without one set + + +def test_pending_submission_carries_yarn_rm_url_snapshot(): + """snapshotting yarn_rm_url lets prepare/confirm survive connection edits.""" + p = PendingSubmission( + pending_id="p_x", + connection="prod", + master="yarn", + deploy_mode="cluster", + yarn_rm_url="http://rm-prod:8088", + script_path="/tmp/j.py", + queue="default", + executor_memory="4G", + executor_cores=2, + num_executors=2, + spark_conf={}, + created_at=datetime(2026, 6, 24), + ) + assert p.yarn_rm_url == "http://rm-prod:8088" def test_pending_submission_can_record_outcome(): diff --git a/tests/unit/test_status_tool.py b/tests/unit/test_status_tool.py index 9469e12..fc49273 100644 --- a/tests/unit/test_status_tool.py +++ b/tests/unit/test_status_tool.py @@ -17,16 +17,19 @@ def test_get_job_status_returns_state(): queue="default", submit_time=datetime(2026, 6, 24), connection="prod", + yarn_rm_url="http://rm:8088", ) ) with patch( "spark_executor.tools.status.get_application_status", return_value=("RUNNING", "State : RUNNING\n"), - ): + ) as m: out = status.get_job_status("abc") assert out.application_id == "application_1" assert out.state == "RUNNING" assert "RUNNING" in out.raw + # yarn_rm_url is forwarded to the REST client + assert m.call_args.args == ("application_1", "http://rm:8088") def test_get_job_status_raises_for_unknown_job(): diff --git a/tests/unit/test_submit_tool.py b/tests/unit/test_submit_tool.py index 13f8503..378aa22 100644 --- a/tests/unit/test_submit_tool.py +++ b/tests/unit/test_submit_tool.py @@ -18,7 +18,12 @@ def _fresh(tmp_path: Path, monkeypatch): monkeypatch.setattr(pending_store, "store", PendingStore()) submit.conn_store = connection_store.store submit.pending_store = pending_store.store - connection_store.store.save(Connection(name="prod", master="yarn", deploy_mode="cluster")) + connection_store.store.save(Connection( + name="prod", + master="yarn", + deploy_mode="cluster", + yarn_rm_url="http://rm:8088", + )) def _last_pending_id() -> str: @@ -45,6 +50,7 @@ def test_prepare_persists_pending_with_snapshot(monkeypatch): name="prod", master="yarn", deploy_mode="cluster", + yarn_rm_url="http://rm:8088", spark_conf={"spark.sql.shuffle.partitions": "200"}, ) ) @@ -54,6 +60,7 @@ def test_prepare_persists_pending_with_snapshot(monkeypatch): assert p.connection == "prod" assert p.master == "yarn" assert p.deploy_mode == "cluster" + assert p.yarn_rm_url == "http://rm:8088" assert p.spark_conf == {"spark.sql.shuffle.partitions": "200"} assert p.queue == "research" assert p.script_path == "/tmp/j.py" @@ -100,6 +107,8 @@ def test_confirm_invokes_spark_submit_and_marks_submitted(monkeypatch): assert p.status == "SUBMITTED" assert p.application_id == "application_17400000001" assert p.job_id is not None + # job carries the connection's yarn_rm_url snapshot + assert submit.job_store.get(p.job_id).yarn_rm_url == "http://rm:8088" def test_confirm_raises_for_unknown_pending_id(): diff --git a/tests/unit/test_yarn_client.py b/tests/unit/test_yarn_client.py index be92a5c..566bcec 100644 --- a/tests/unit/test_yarn_client.py +++ b/tests/unit/test_yarn_client.py @@ -1,9 +1,11 @@ # coding=utf-8 -from unittest.mock import MagicMock, patch +from unittest.mock import patch +import httpx import pytest from spark_executor.core.yarn_client import ( + YarnConfigError, YarnError, get_application_logs, get_application_status, @@ -11,53 +13,120 @@ from spark_executor.core.yarn_client import ( ) -def _fake_proc(returncode: int, stdout: str = "", stderr: str = ""): - p = MagicMock() - p.returncode = returncode - p.stdout = stdout - p.stderr = stderr - return p +RM = "http://rm:8088" -def test_status_parses_state_line(): - fake = _fake_proc( - 0, - stdout="Application Report :\n State : RUNNING\n ...\n", - ) - with patch("spark_executor.core.yarn_client.subprocess.run", return_value=fake): - state, raw = get_application_status("application_1") +def _resp(status: int, *, json_data=None, text: str | None = None) -> httpx.Response: + if json_data is not None: + return httpx.Response(status, json=json_data) + return httpx.Response(status, text=text or "") + + +# --- get_application_status --- + +def test_status_parses_app_state(): + fake = _resp(200, json_data={"app": {"id": "application_1", "state": "RUNNING"}}) + with patch("spark_executor.core.yarn_client.httpx.request", return_value=fake) as m: + state, raw = get_application_status("application_1", RM) assert state == "RUNNING" assert "RUNNING" in raw + args = m.call_args.args + assert args == ("GET", f"{RM}/ws/v1/cluster/apps/application_1") -def test_status_raises_on_nonzero_return(): - fake = _fake_proc(1, stderr="not found") - with patch("spark_executor.core.yarn_client.subprocess.run", return_value=fake): - with pytest.raises(YarnError): - get_application_status("application_x") +def test_status_raises_on_404(): + with patch("spark_executor.core.yarn_client.httpx.request", return_value=_resp(404)): + with pytest.raises(YarnError, match="not found"): + get_application_status("application_x", RM) -def test_logs_returns_stdout(): - fake = _fake_proc(0, stdout="log line 1\nlog line 2\n") - with patch("spark_executor.core.yarn_client.subprocess.run", return_value=fake) as m: - out = get_application_logs("application_1") +def test_status_raises_on_5xx(): + with patch( + "spark_executor.core.yarn_client.httpx.request", + return_value=_resp(503, text="upstream down"), + ): + with pytest.raises(YarnError, match="503"): + get_application_status("application_1", RM) + + +def test_status_raises_when_state_field_missing(): + fake = _resp(200, json_data={"app": {"id": "application_1"}}) + with patch("spark_executor.core.yarn_client.httpx.request", return_value=fake): + with pytest.raises(YarnError, match="Could not parse YARN state"): + get_application_status("application_1", RM) + + +def test_status_requires_rm_url(): + with patch.dict("os.environ", {}, clear=True): + with pytest.raises(YarnConfigError): + get_application_status("application_1", None) + + +def test_status_falls_back_to_env_var(): + fake = _resp(200, json_data={"app": {"state": "FINISHED"}}) + with patch.dict("os.environ", {"YARN_RESOURCE_MANAGER_URL": "http://env-rm:8088"}, clear=False): + with patch("spark_executor.core.yarn_client.httpx.request", return_value=fake) as m: + state, _ = get_application_status("application_1", None) + assert state == "FINISHED" + assert "env-rm:8088" in m.call_args.args[1] + + +def test_status_rejects_non_http_url(): + with pytest.raises(YarnConfigError, match="must start with"): + get_application_status("application_1", "rm:8088") + + +# --- get_application_logs --- + +def test_logs_returns_text(): + fake = _resp(200, text="log line 1\nlog line 2\n") + with patch("spark_executor.core.yarn_client.httpx.request", return_value=fake) as m: + out = get_application_logs("application_1", RM) assert out == "log line 1\nlog line 2\n" - args = m.call_args.args[0] - assert args[:3] == ["yarn", "logs", "-applicationId"] - assert args[3] == "application_1" + assert m.call_args.args == ("GET", f"{RM}/ws/v1/cluster/apps/application_1/aggregated-logs") -def test_kill_invokes_yarn_application_kill(): - fake = _fake_proc(0) - with patch("spark_executor.core.yarn_client.subprocess.run", return_value=fake) as m: - kill_application("application_1") - args = m.call_args.args[0] - assert args[:3] == ["yarn", "application", "-kill"] - assert args[3] == "application_1" +def test_logs_raises_on_404_with_explanation(): + with patch("spark_executor.core.yarn_client.httpx.request", return_value=_resp(404)): + with pytest.raises(YarnError, match="log-aggregation-enable"): + get_application_logs("application_1", RM) -def test_kill_raises_on_nonzero_return(): - fake = _fake_proc(1, stderr="denied") - with patch("spark_executor.core.yarn_client.subprocess.run", return_value=fake): +def test_logs_raises_on_5xx(): + with patch( + "spark_executor.core.yarn_client.httpx.request", + return_value=_resp(500, text="boom"), + ): with pytest.raises(YarnError): - kill_application("application_1") + get_application_logs("application_1", RM) + + +# --- kill_application --- + +def test_kill_sends_put_with_killed_state(): + fake = _resp(200, json_data={"app": {"state": "KILLED"}}) + with patch("spark_executor.core.yarn_client.httpx.request", return_value=fake) as m: + kill_application("application_1", RM) + args = m.call_args.args + assert args == ("PUT", f"{RM}/ws/v1/cluster/apps/application_1/state") + assert m.call_args.kwargs["json"] == {"state": "KILLED"} + + +def test_kill_raises_on_5xx(): + with patch( + "spark_executor.core.yarn_client.httpx.request", + return_value=_resp(403, text="forbidden"), + ): + with pytest.raises(YarnError, match="403"): + kill_application("application_1", RM) + + +# --- connection errors --- + +def test_status_wraps_httpx_errors_as_yarn_error(): + with patch( + "spark_executor.core.yarn_client.httpx.request", + side_effect=httpx.ConnectError("connection refused"), + ): + with pytest.raises(YarnError, match="connection failed"): + get_application_status("application_1", RM)