refactor: replace yarn CLI shell-out with YARN REST API

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.
This commit is contained in:
Claude
2026-06-24 17:33:36 +08:00
parent 0d7b1d1ec3
commit b3564737f7
12 changed files with 252 additions and 91 deletions
+2 -1
View File
@@ -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",
+1
View File
@@ -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",
)
)
+34
View File
@@ -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():
+4 -1
View File
@@ -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():
+10 -1
View File
@@ -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():
+106 -37
View File
@@ -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)