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:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} "
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user