# Spark Executor MCP Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a three-stage MCP server that lets an LLM Agent submit, monitor, fetch logs from, and kill PySpark jobs on YARN — escalating from a synchronous minimum-viable tool set to an async, multi-tenant job platform backed by SQLite.
**Architecture:** FastAPI app exposes MCP tools (via `fastapi-mcp`) that wrap shell invocations of `spark-submit` and `yarn`. Stage 1 uses synchronous `subprocess.run` and an in-memory job map. Stage 2 adds an LLM-facing `write_job_file` helper. Stage 3 refactors submission to `asyncio.create_subprocess_exec`, persists job state in SQLite, and introduces offset-based log caching.
**Tech Stack:** Python 3.12+, FastAPI 0.138+, fastapi-mcp 0.4+, Pydantic 2.13+, loguru, httpx (already in `pyproject.toml`); uv for env mgmt; pytest for tests; SQLite (stdlib `sqlite3`) for Stage 3 registry.
---
## File Structure
Stage 1 (this plan) creates:
```
spark_executor/
__init__.py
server.py # FastAPI app, mounts MCP tools
tools/
__init__.py
submit.py # submit_job MCP tool (uses a saved Connection)
status.py # get_job_status MCP tool
logs.py # get_job_logs MCP tool
kill.py # kill_job MCP tool
connections.py # save/list/get/delete_connection MCP tools
core/
__init__.py
spark_submit.py # builds & runs spark-submit commands
yarn_client.py # wraps yarn application / yarn logs
log_parser.py # extracts application_id from spark-submit output
job_store.py # in-memory dict: job_id -> {application_id, ...}
connection_store.py # JSON CRUD over ./data/connections.json
models.py # Pydantic: Job, JobStatus, SubmitResult, Connection
data/
connections.json # created on first save_connection (gitignored)
tests/
__init__.py
conftest.py
unit/
test_log_parser.py
test_spark_submit.py
test_yarn_client.py
test_connection_store.py
test_connection_tools.py
test_submit_tool.py
test_status_tool.py
test_logs_tool.py
test_kill_tool.py
```
Stage 2 adds:
```
spark_executor/
tools/
generate.py # write_job_file MCP tool
core/
job_writer.py # writes PySpark code to /tmp/jobs/job_xxx.py
tests/
unit/
test_write_job_tool.py
test_job_writer.py
```
Stage 3 adds (in a follow-on plan, not implemented here):
```
spark_executor/
core/
job_registry.py # SQLite-backed CRUD
log_cache.py # offset-based yarn-logs cache
async_submitter.py # asyncio.create_subprocess_exec wrapper
status_poller.py # background task updating registry
```
---
## Global Constraints
- Python ≥ 3.12 (per `pyproject.toml`).
- Module header on every new Python file: `# coding=utf-8` + `@Time` / `@Author` docstring, matching the existing `common/` and `spark_executor/` files.
- Logging: import `from common.logging import logger` (the shared loguru logger). Do not instantiate new loggers.
- MCP tools are registered by decorating FastAPI routes on the `app` in `spark_executor/server.py`; `fastapi-mcp` auto-discovers them. No manual MCP registration code.
- Subprocess calls must capture stdout AND stderr (Spark writes the application_id line to stderr, not stdout). This is true for both `spark-submit` and `yarn application -status`.
- Default spark-submit flags (Stage 1, fixed): `--master` and `--deploy-mode` are derived from the `Connection` passed to `submit_job` (see Task 11). Other params are user-tunable.
- `Connection` records persist to `./data/connections.json` by default. Override with `SPARK_EXECUTOR_DATA_DIR` env var (the file path becomes `
/connections.json`). The directory is auto-created on first write. `./data/` is added to `.gitignore` so credentials/configs are not committed.
- All test commands use the venv: `uv run pytest ...` (or activate `.venv/bin/activate` first).
- Every task ends in a commit. Commit messages use conventional-commits prefixes: `test:`, `feat:`, `chore:`, `refactor:`.
- YAGNI: do not introduce the SQLite registry, async execution, log cache, or ownership filters in Stage 1 tasks. They are out of scope and belong to Stage 3.
---
# Stage 1 — Minimum Closed Loop (★☆☆☆☆)
Target: 4 MCP tools (`submit_job`, `get_job_status`, `get_job_logs`, `kill_job`) backed by synchronous `subprocess.run`. ~300–500 lines of Python.
---
### Task 1: Test infrastructure and Pydantic models
**Files:**
- Create: `tests/__init__.py`
- Create: `tests/conftest.py`
- Create: `tests/unit/__init__.py`
- Create: `spark_executor/models.py`
- Create: `spark_executor/core/__init__.py`
- Create: `spark_executor/tools/__init__.py`
- Modify: `pyproject.toml` (add `[dependency-groups].dev` with `pytest`)
- Test: `tests/unit/test_models.py`
**Interfaces:**
- Produces:
- `class Job(BaseModel)` with fields `job_id: str`, `application_id: str`, `script_path: str`, `queue: str`, `submit_time: datetime`, `connection: str` (name of the `Connection` used to submit)
- `class JobStatus(BaseModel)` with fields `application_id: str`, `state: str`, `raw: str`
- `class SubmitResult(BaseModel)` with fields `job_id: str`, `application_id: str`, `tracking_url: str | None`
- `class Connection(BaseModel)` with fields `name: str`, `master: str`, `deploy_mode: str = "cluster"`, `yarn_rm_url: str | None = None`, `spark_conf: dict[str, str] = Field(default_factory=dict)`
- `class PendingSubmission(BaseModel)` with fields `pending_id: str`, `connection: str`, `master: str`, `deploy_mode: str`, `script_path: str`, `queue: str`, `executor_memory: str`, `executor_cores: int`, `num_executors: int`, `spark_conf: dict[str, str]`, `created_at: datetime`, `status: str = "PENDING"` (one of `"PENDING" | "SUBMITTED" | "CANCELLED" | "FAILED"`), `error: str | None = None`, `job_id: str | None = None`, `application_id: str | None = None`. The `master` / `deploy_mode` / `spark_conf` fields are a snapshot of the source `Connection` taken at `prepare_submit_job` time so that editing the connection between prepare and confirm does not silently change the submission target.
- [ ] **Step 1: Add pytest to dev dependencies**
Edit `pyproject.toml`, appending at the end:
```toml
[dependency-groups]
dev = [
"pytest>=8.0",
"pytest-mock>=3.14",
]
```
Then run:
```bash
cd /Users/taochen/llm/spark-executor-mcp && uv sync
```
Expected: `uv.lock` updated; `pytest` available in `.venv/bin/`.
- [ ] **Step 2: Create package skeleton with empty `__init__.py` files**
```bash
mkdir -p tests/unit spark_executor/core spark_executor/tools
```
Each new `__init__.py` gets the standard header:
```python
# coding=utf-8
"""
@Time :2026/6/24
@Author :tao.chen
"""
```
- [ ] **Step 3: Write the failing test for models**
`tests/unit/test_models.py`:
```python
# coding=utf-8
from datetime import datetime
from spark_executor.models import Connection, Job, JobStatus, PendingSubmission, SubmitResult
def test_job_roundtrip():
job = Job(
job_id="abc123",
application_id="application_17400000001",
script_path="/tmp/jobs/job_001.py",
queue="default",
submit_time=datetime(2026, 6, 24, 10, 0, 0),
connection="prod-yarn",
)
dumped = job.model_dump()
assert dumped["job_id"] == "abc123"
assert dumped["application_id"] == "application_17400000001"
assert dumped["connection"] == "prod-yarn"
def test_job_status_default_raw():
s = JobStatus(application_id="application_1", state="RUNNING")
assert s.raw == ""
def test_submit_result_tracking_url_optional():
r = SubmitResult(job_id="j1", application_id="application_1", tracking_url=None)
assert r.tracking_url is None
def test_connection_defaults():
c = Connection(name="prod", master="yarn")
assert c.deploy_mode == "cluster"
assert c.yarn_rm_url is None
assert c.spark_conf == {}
def test_connection_with_spark_conf():
c = Connection(
name="dev",
master="spark://master:7077",
deploy_mode="client",
spark_conf={"spark.sql.shuffle.partitions": "200"},
)
assert c.spark_conf["spark.sql.shuffle.partitions"] == "200"
def test_pending_submission_defaults_to_pending_status():
p = PendingSubmission(
pending_id="p_1",
connection="prod",
master="yarn",
deploy_mode="cluster",
script_path="/tmp/j.py",
queue="default",
executor_memory="2G",
executor_cores=2,
num_executors=2,
spark_conf={},
created_at=datetime(2026, 6, 24),
)
assert p.status == "PENDING"
assert p.error is None
assert p.job_id is None
assert p.application_id is None
def test_pending_submission_can_record_outcome():
p = PendingSubmission(
pending_id="p_2",
connection="prod",
master="yarn",
deploy_mode="cluster",
script_path="/tmp/j.py",
queue="default",
executor_memory="2G",
executor_cores=2,
num_executors=2,
spark_conf={},
created_at=datetime(2026, 6, 24),
status="SUBMITTED",
job_id="j_abc",
application_id="application_17400000001",
)
assert p.status == "SUBMITTED"
assert p.job_id == "j_abc"
assert p.application_id == "application_17400000001"
```
- [ ] **Step 4: Run test to verify it fails**
```bash
cd /Users/taochen/llm/spark-executor-mcp && uv run pytest tests/unit/test_models.py -v
```
Expected: `ModuleNotFoundError: No module named 'spark_executor.models'`.
- [ ] **Step 5: Implement `spark_executor/models.py`**
```python
# coding=utf-8
"""
@Time :2026/6/24
@Author :tao.chen
"""
from datetime import datetime
from pydantic import BaseModel, Field
class Job(BaseModel):
job_id: str
application_id: str
script_path: str
queue: str
submit_time: datetime
connection: str
class JobStatus(BaseModel):
application_id: str
state: str
raw: str = Field(default="")
class SubmitResult(BaseModel):
job_id: str
application_id: str
tracking_url: str | None = None
class Connection(BaseModel):
name: str
master: str
deploy_mode: str = "cluster"
yarn_rm_url: str | None = None
spark_conf: dict[str, str] = Field(default_factory=dict)
class PendingSubmission(BaseModel):
pending_id: str
connection: str
master: str
deploy_mode: str
script_path: str
queue: str
executor_memory: str
executor_cores: int
num_executors: int
spark_conf: dict[str, str] = Field(default_factory=dict)
created_at: datetime
status: str = "PENDING" # PENDING | SUBMITTED | CANCELLED | FAILED
error: str | None = None
job_id: str | None = None
application_id: str | None = None
```
- [ ] **Step 6: Run test to verify it passes**
```bash
uv run pytest tests/unit/test_models.py -v
```
Expected: 8 passed.
- [ ] **Step 7: Create `conftest.py` to ensure `spark_executor` is importable**
`tests/conftest.py`:
```python
# coding=utf-8
# Ensures the project root is on sys.path when running pytest from any cwd.
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
```
- [ ] **Step 7b: Add `./data/` to `.gitignore`**
Create `.gitignore` (or append, if it exists):
```
data/
```
So credentials and config blobs are not committed.
- [ ] **Step 8: Commit**
```bash
git add pyproject.toml uv.lock tests spark_executor/models.py spark_executor/core/__init__.py spark_executor/tools/__init__.py .gitignore
git commit -m "chore: add pytest dev-deps and pydantic models (Job, Connection)"
```
---
### Task 2: `log_parser` — extract `application_id` and tracking URL from `spark-submit` output
Spark writes the application_id to stderr in one of two forms:
```
Submitted application application_17400000001
```
or
```
tracking URL: http://rm:8088/proxy/application_17400000001/
```
**Files:**
- Create: `spark_executor/core/log_parser.py`
- Test: `tests/unit/test_log_parser.py`
**Interfaces:**
- Produces:
- `def parse_spark_submit_output(stderr: str) -> tuple[str, str | None]` — returns `(application_id, tracking_url)`. Raises `ValueError` if no `application_id` is found.
- [ ] **Step 1: Write the failing tests**
`tests/unit/test_log_parser.py`:
```python
# coding=utf-8
import pytest
from spark_executor.core.log_parser import parse_spark_submit_output
def test_parses_submitted_application_line():
stderr = "Warning: ignoring...\nSubmitted application application_17400000001\n"
app_id, url = parse_spark_submit_output(stderr)
assert app_id == "application_17400000001"
assert url is None
def test_parses_tracking_url_line():
stderr = "tracking URL: http://rm:8088/proxy/application_17400000002/\n"
app_id, url = parse_spark_submit_output(stderr)
assert app_id == "application_17400000002"
assert url == "http://rm:8088/proxy/application_17400000002/"
def test_prefers_submitted_application_line_over_tracking_url():
stderr = (
"tracking URL: http://rm:8088/proxy/application_9999/\n"
"Submitted application application_1234\n"
)
app_id, _ = parse_spark_submit_output(stderr)
assert app_id == "application_1234"
def test_raises_when_no_application_id_found():
with pytest.raises(ValueError, match="application_id"):
parse_spark_submit_output("some unrelated output\n")
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
uv run pytest tests/unit/test_log_parser.py -v
```
Expected: `ModuleNotFoundError: No module named 'spark_executor.core.log_parser'`.
- [ ] **Step 3: Implement `log_parser.py`**
```python
# coding=utf-8
"""
@Time :2026/6/24
@Author :tao.chen
"""
import re
_APP_ID_RE = re.compile(r"Submitted application (\S+)")
_TRACKING_URL_RE = re.compile(r"tracking URL:\s+(\S+)")
def parse_spark_submit_output(stderr: str) -> tuple[str, str | None]:
"""Extract (application_id, tracking_url) from spark-submit stderr.
Raises ValueError if no application_id can be found.
"""
app_match = _APP_ID_RE.search(stderr)
if app_match:
application_id = app_match.group(1)
else:
url_match = _TRACKING_URL_RE.search(stderr)
if not url_match:
raise ValueError("Could not find application_id in spark-submit output")
# tracking URL is of the form http://rm:8088/proxy/application_xxx/
tracking_url = url_match.group(1)
tail = tracking_url.rstrip("/").rsplit("/", 1)[-1]
if not tail.startswith("application_"):
raise ValueError("Could not find application_id in spark-submit output")
application_id = tail
url_match = _TRACKING_URL_RE.search(stderr)
tracking_url = url_match.group(1) if url_match else None
return application_id, tracking_url
```
- [ ] **Step 4: Run tests to verify they pass**
```bash
uv run pytest tests/unit/test_log_parser.py -v
```
Expected: 4 passed.
- [ ] **Step 5: Commit**
```bash
git add spark_executor/core/log_parser.py tests/unit/test_log_parser.py
git commit -m "feat: add spark-submit output parser"
```
---
### Task 3: `spark_submit` — build and run a `spark-submit` command
**Files:**
- Create: `spark_executor/core/spark_submit.py`
- Test: `tests/unit/test_spark_submit.py`
**Interfaces:**
- Produces:
- `def build_spark_submit_command(*, master: str, deploy_mode: str, script_path: str, queue: str, executor_memory: str, executor_cores: int, num_executors: int, spark_conf: dict[str, str] | None = None) -> list[str]` — `--master` and `--deploy-mode` come from the `Connection` chosen by the caller (see Task 11). `spark_conf` entries are emitted as `--conf k=v` pairs in declaration order.
- `def run_spark_submit(cmd: list[str]) -> subprocess.CompletedProcess[str]` — runs the command, returns the CompletedProcess. Stdout AND stderr captured (text mode, `errors="replace"`).
- `class SparkSubmitError(Exception)`
- [ ] **Step 1: Write the failing tests**
`tests/unit/test_spark_submit.py`:
```python
# coding=utf-8
from unittest.mock import MagicMock, patch
import pytest
from spark_executor.core.spark_submit import (
SparkSubmitError,
build_spark_submit_command,
run_spark_submit,
)
def test_build_command_uses_provided_master_and_deploy_mode():
cmd = build_spark_submit_command(
master="yarn",
deploy_mode="cluster",
script_path="/tmp/jobs/job_001.py",
queue="default",
executor_memory="2G",
executor_cores=2,
num_executors=2,
)
assert cmd[:2] == ["spark-submit", "--master"]
assert "yarn" in cmd
assert "cluster" in cmd
assert "--queue" in cmd and "default" in cmd
assert "--executor-memory" in cmd and "2G" in cmd
assert "--executor-cores" in cmd and "2" in cmd
assert "--num-executors" in cmd and "2" in cmd
assert cmd[-1] == "/tmp/jobs/job_001.py"
def test_build_command_supports_standalone_master():
cmd = build_spark_submit_command(
master="spark://master:7077",
deploy_mode="client",
script_path="/tmp/j.py",
queue="default",
executor_memory="1G",
executor_cores=1,
num_executors=1,
)
assert "spark://master:7077" in cmd
assert "client" in cmd
def test_build_command_appends_spark_conf_entries():
cmd = build_spark_submit_command(
master="yarn",
deploy_mode="cluster",
script_path="/tmp/j.py",
queue="default",
executor_memory="2G",
executor_cores=2,
num_executors=2,
spark_conf={"spark.sql.shuffle.partitions": "200", "spark.executor.memoryOverhead": "1G"},
)
# every k=v is emitted as --conf k=v
assert "--conf" in cmd
assert "spark.sql.shuffle.partitions=200" in cmd
assert "spark.executor.memoryOverhead=1G" in cmd
# script_path is still last
assert cmd[-1] == "/tmp/j.py"
def test_run_spark_submit_returns_completed_process(monkeypatch):
fake = MagicMock()
fake.returncode = 0
fake.stderr = "Submitted application application_1\n"
with patch("spark_executor.core.spark_submit.subprocess.run", return_value=fake) as m:
result = run_spark_submit(["spark-submit", "/tmp/x.py"])
assert result is fake
m.assert_called_once()
# capture_output=True and text=True must be set
kwargs = m.call_args.kwargs
assert kwargs["capture_output"] is True
assert kwargs["text"] is True
def test_run_spark_submit_raises_on_nonzero_return():
fake = MagicMock()
fake.returncode = 1
fake.stderr = "boom"
with patch("spark_executor.core.spark_submit.subprocess.run", return_value=fake):
with pytest.raises(SparkSubmitError, match="spark-submit failed"):
run_spark_submit(["spark-submit", "/tmp/x.py"])
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
uv run pytest tests/unit/test_spark_submit.py -v
```
Expected: `ModuleNotFoundError: No module named 'spark_executor.core.spark_submit'`.
- [ ] **Step 3: Implement `spark_submit.py`**
```python
# coding=utf-8
"""
@Time :2026/6/24
@Author :tao.chen
"""
import subprocess
class SparkSubmitError(Exception):
"""Raised when spark-submit exits with a non-zero return code."""
def build_spark_submit_command(
*,
master: str,
deploy_mode: str,
script_path: str,
queue: str,
executor_memory: str,
executor_cores: int,
num_executors: int,
spark_conf: dict[str, str] | None = None,
) -> list[str]:
cmd = [
"spark-submit",
"--master", master,
"--deploy-mode", deploy_mode,
"--queue", queue,
"--executor-memory", executor_memory,
"--executor-cores", str(executor_cores),
"--num-executors", str(num_executors),
]
for key, value in (spark_conf or {}).items():
cmd.extend(["--conf", f"{key}={value}"])
cmd.append(script_path)
return cmd
def run_spark_submit(cmd: list[str]) -> "subprocess.CompletedProcess[str]":
result = subprocess.run(cmd, capture_output=True, text=True, errors="replace")
if result.returncode != 0:
raise SparkSubmitError(
f"spark-submit failed (rc={result.returncode}): {result.stderr}"
)
return result
```
- [ ] **Step 4: Run tests to verify they pass**
```bash
uv run pytest tests/unit/test_spark_submit.py -v
```
Expected: 5 passed.
- [ ] **Step 5: Commit**
```bash
git add spark_executor/core/spark_submit.py tests/unit/test_spark_submit.py
git commit -m "feat: add spark-submit command builder and runner"
```
---
### Task 4: `yarn_client` — wrap `yarn application -status`, `yarn logs`, `yarn application -kill`
**Files:**
- Create: `spark_executor/core/yarn_client.py`
- Test: `tests/unit/test_yarn_client.py`
**Interfaces:**
- Produces:
- `def get_application_status(application_id: str) -> tuple[str, str]` — returns `(state, raw_output)`. State is the value after `State :` in the output. Raises `YarnError` if yarn exits non-zero.
- `def get_application_logs(application_id: str) -> str` — returns full `yarn logs -applicationId ...` stdout. Raises `YarnError` on failure.
- `def kill_application(application_id: str) -> None` — runs `yarn application -kill ...`. Raises `YarnError` on failure.
- `class YarnError(Exception)`
- [ ] **Step 1: Write the failing tests**
`tests/unit/test_yarn_client.py`:
```python
# coding=utf-8
from unittest.mock import MagicMock, patch
import pytest
from spark_executor.core.yarn_client import (
YarnError,
get_application_logs,
get_application_status,
kill_application,
)
def _fake_proc(returncode: int, stdout: str = "", stderr: str = ""):
p = MagicMock()
p.returncode = returncode
p.stdout = stdout
p.stderr = stderr
return p
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")
assert state == "RUNNING"
assert "RUNNING" in raw
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_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")
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"
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_kill_raises_on_nonzero_return():
fake = _fake_proc(1, stderr="denied")
with patch("spark_executor.core.yarn_client.subprocess.run", return_value=fake):
with pytest.raises(YarnError):
kill_application("application_1")
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
uv run pytest tests/unit/test_yarn_client.py -v
```
Expected: `ModuleNotFoundError: No module named 'spark_executor.core.yarn_client'`.
- [ ] **Step 3: Implement `yarn_client.py`**
```python
# coding=utf-8
"""
@Time :2026/6/24
@Author :tao.chen
"""
import re
import subprocess
class YarnError(Exception):
"""Raised when a yarn CLI invocation fails."""
_STATE_RE = re.compile(r"State\s*:\s*(\S+)")
def _run(cmd: list[str]) -> "subprocess.CompletedProcess[str]":
return subprocess.run(cmd, capture_output=True, text=True, errors="replace")
def get_application_status(application_id: str) -> tuple[str, str]:
proc = _run(["yarn", "application", "-status", application_id])
if proc.returncode != 0:
raise YarnError(
f"yarn application -status failed (rc={proc.returncode}): {proc.stderr}"
)
match = _STATE_RE.search(proc.stdout)
if not match:
raise YarnError(f"Could not parse YARN state from output: {proc.stdout!r}")
return match.group(1), proc.stdout
def get_application_logs(application_id: str) -> str:
proc = _run(["yarn", "logs", "-applicationId", application_id])
if proc.returncode != 0:
raise YarnError(
f"yarn logs failed (rc={proc.returncode}): {proc.stderr}"
)
return proc.stdout
def kill_application(application_id: str) -> None:
proc = _run(["yarn", "application", "-kill", application_id])
if proc.returncode != 0:
raise YarnError(
f"yarn application -kill failed (rc={proc.returncode}): {proc.stderr}"
)
```
- [ ] **Step 4: Run tests to verify they pass**
```bash
uv run pytest tests/unit/test_yarn_client.py -v
```
Expected: 5 passed.
- [ ] **Step 5: Commit**
```bash
git add spark_executor/core/yarn_client.py tests/unit/test_yarn_client.py
git commit -m "feat: add yarn client (status, logs, kill)"
```
---
### Task 5: `job_store` — in-memory job_id → Job registry
**Files:**
- Create: `spark_executor/core/job_store.py`
- Test: `tests/unit/test_job_store.py`
**Interfaces:**
- Produces:
- `class JobStore` with methods:
- `put(job: Job) -> None`
- `get(job_id: str) -> Job | None`
- `list() -> list[Job]`
Stage 1 keeps this trivial (a `dict` behind a class). Stage 3 replaces the implementation with SQLite.
- [ ] **Step 1: Write the failing test**
`tests/unit/test_job_store.py`:
```python
# coding=utf-8
from datetime import datetime
from spark_executor.core.job_store import JobStore
from spark_executor.models import Job
def _job(jid: str) -> Job:
return Job(
job_id=jid,
application_id=f"application_{jid}",
script_path="/tmp/j.py",
queue="default",
submit_time=datetime(2026, 6, 24),
)
def test_put_then_get_roundtrip():
store = JobStore()
store.put(_job("a"))
assert store.get("a") is not None
assert store.get("a").application_id == "application_a"
def test_get_missing_returns_none():
store = JobStore()
assert store.get("nope") is None
def test_list_returns_all_jobs():
store = JobStore()
store.put(_job("a"))
store.put(_job("b"))
assert {j.job_id for j in store.list()} == {"a", "b"}
```
- [ ] **Step 2: Run test to verify it fails**
```bash
uv run pytest tests/unit/test_job_store.py -v
```
Expected: `ModuleNotFoundError: No module named 'spark_executor.core.job_store'`.
- [ ] **Step 3: Implement `job_store.py`**
```python
# coding=utf-8
"""
@Time :2026/6/24
@Author :tao.chen
"""
from threading import Lock
from spark_executor.models import Job
class JobStore:
"""In-memory job registry. Stage-3 will swap this for SQLite."""
def __init__(self) -> None:
self._lock = Lock()
self._jobs: dict[str, Job] = {}
def put(self, job: Job) -> None:
with self._lock:
self._jobs[job.job_id] = job
def get(self, job_id: str) -> Job | None:
with self._lock:
return self._jobs.get(job_id)
def list(self) -> list[Job]:
with self._lock:
return list(self._jobs.values())
```
- [ ] **Step 4: Run test to verify it passes**
```bash
uv run pytest tests/unit/test_job_store.py -v
```
Expected: 3 passed.
- [ ] **Step 5: Commit**
```bash
git add spark_executor/core/job_store.py tests/unit/test_job_store.py
git commit -m "feat: add in-memory JobStore"
```
---
### Task 6: `connection_store` — JSON-backed Connection CRUD
**Files:**
- Create: `spark_executor/core/connection_store.py`
- Test: `tests/unit/test_connection_store.py`
**Interfaces:**
- Produces:
- `class ConnectionStore` with methods:
- `list_all() -> list[Connection]`
- `get(name: str) -> Connection | None`
- `save(conn: Connection) -> None` — upsert by `conn.name`, then persist to disk atomically
- `delete(name: str) -> bool` — returns True if a connection was removed
- Module-level singleton `store: ConnectionStore`, defaulting to `./data/connections.json` (overridable via `SPARK_EXECUTOR_DATA_DIR` env var).
- `class ConnectionNotFound(KeyError)` — raised by tool layer when a name is missing.
- [ ] **Step 1: Write the failing test**
`tests/unit/test_connection_store.py`:
```python
# coding=utf-8
import json
from pathlib import Path
from spark_executor.core import connection_store
from spark_executor.models import Connection
def _conn(name: str) -> Connection:
return Connection(name=name, master="yarn")
def test_save_then_get_roundtrip(tmp_path: Path, monkeypatch):
monkeypatch.setattr(connection_store, "DEFAULT_DATA_DIR", str(tmp_path))
monkeypatch.setattr(connection_store, "store", connection_store.ConnectionStore())
connection_store.store.save(_conn("prod"))
assert connection_store.store.get("prod") is not None
assert connection_store.store.get("prod").master == "yarn"
def test_save_persists_to_json_file(tmp_path: Path, monkeypatch):
monkeypatch.setattr(connection_store, "DEFAULT_DATA_DIR", str(tmp_path))
monkeypatch.setattr(connection_store, "store", connection_store.ConnectionStore())
connection_store.store.save(_conn("prod"))
path = tmp_path / "connections.json"
assert path.is_file()
payload = json.loads(path.read_text())
assert "prod" in payload
assert payload["prod"]["master"] == "yarn"
def test_get_missing_returns_none(tmp_path: Path, monkeypatch):
monkeypatch.setattr(connection_store, "DEFAULT_DATA_DIR", str(tmp_path))
monkeypatch.setattr(connection_store, "store", connection_store.ConnectionStore())
assert connection_store.store.get("missing") is None
def test_list_all_returns_empty_when_file_absent(tmp_path: Path, monkeypatch):
monkeypatch.setattr(connection_store, "DEFAULT_DATA_DIR", str(tmp_path))
monkeypatch.setattr(connection_store, "store", connection_store.ConnectionStore())
assert connection_store.store.list_all() == []
def test_list_all_returns_saved_connections(tmp_path: Path, monkeypatch):
monkeypatch.setattr(connection_store, "DEFAULT_DATA_DIR", str(tmp_path))
monkeypatch.setattr(connection_store, "store", connection_store.ConnectionStore())
connection_store.store.save(_conn("a"))
connection_store.store.save(_conn("b"))
names = {c.name for c in connection_store.store.list_all()}
assert names == {"a", "b"}
def test_save_upserts_existing(tmp_path: Path, monkeypatch):
monkeypatch.setattr(connection_store, "DEFAULT_DATA_DIR", str(tmp_path))
monkeypatch.setattr(connection_store, "store", connection_store.ConnectionStore())
connection_store.store.save(_conn("prod"))
updated = Connection(name="prod", master="spark://new:7077", deploy_mode="client")
connection_store.store.save(updated)
assert connection_store.store.get("prod").master == "spark://new:7077"
assert connection_store.store.get("prod").deploy_mode == "client"
def test_delete_returns_true_when_present(tmp_path: Path, monkeypatch):
monkeypatch.setattr(connection_store, "DEFAULT_DATA_DIR", str(tmp_path))
monkeypatch.setattr(connection_store, "store", connection_store.ConnectionStore())
connection_store.store.save(_conn("prod"))
assert connection_store.store.delete("prod") is True
assert connection_store.store.get("prod") is None
def test_delete_returns_false_when_absent(tmp_path: Path, monkeypatch):
monkeypatch.setattr(connection_store, "DEFAULT_DATA_DIR", str(tmp_path))
monkeypatch.setattr(connection_store, "store", connection_store.ConnectionStore())
assert connection_store.store.delete("nope") is False
```
- [ ] **Step 2: Run test to verify it fails**
```bash
uv run pytest tests/unit/test_connection_store.py -v
```
Expected: `ModuleNotFoundError: No module named 'spark_executor.core.connection_store'`.
- [ ] **Step 3: Implement `connection_store.py`**
```python
# coding=utf-8
"""
@Time :2026/6/24
@Author :tao.chen
"""
import json
import os
import tempfile
from pathlib import Path
from threading import Lock
from pydantic import ValidationError
from spark_executor.models import Connection
DEFAULT_DATA_DIR = os.environ.get("SPARK_EXECUTOR_DATA_DIR", "./data")
DEFAULT_FILE_NAME = "connections.json"
class ConnectionNotFound(KeyError):
"""Raised when a named connection does not exist."""
class ConnectionStore:
"""JSON-backed CRUD for Connection records.
File path: /.
Writes are atomic: tempfile + os.replace.
"""
def __init__(self, data_dir: str | None = None, file_name: str = DEFAULT_FILE_NAME) -> None:
self._data_dir = data_dir or DEFAULT_DATA_DIR
self._file_name = file_name
self._lock = Lock()
@property
def path(self) -> Path:
return Path(self._data_dir) / self._file_name
def _load(self) -> dict[str, Connection]:
if not self.path.exists():
return {}
raw = json.loads(self.path.read_text(encoding="utf-8"))
return {name: Connection.model_validate(c) for name, c in raw.items()}
def _dump(self, records: dict[str, Connection]) -> None:
os.makedirs(self._data_dir, exist_ok=True)
payload = {name: c.model_dump() for name, c in records.items()}
# atomic write: tempfile in same dir, then replace
fd, tmp_path = tempfile.mkstemp(
prefix=self._file_name + ".", dir=self._data_dir
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2, ensure_ascii=False)
os.replace(tmp_path, self.path)
except Exception:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
raise
def list_all(self) -> list[Connection]:
with self._lock:
return list(self._load().values())
def get(self, name: str) -> Connection | None:
with self._lock:
return self._load().get(name)
def save(self, conn: Connection) -> None:
with self._lock:
records = self._load()
records[conn.name] = conn
self._dump(records)
def delete(self, name: str) -> bool:
with self._lock:
records = self._load()
if name not in records:
return False
del records[name]
self._dump(records)
return True
# Module-level singleton; replaced in tests.
store = ConnectionStore()
```
- [ ] **Step 4: Run test to verify it passes**
```bash
uv run pytest tests/unit/test_connection_store.py -v
```
Expected: 8 passed.
- [ ] **Step 5: Commit**
```bash
git add spark_executor/core/connection_store.py tests/unit/test_connection_store.py
git commit -m "feat: add JSON-backed ConnectionStore"
```
---
### Task 7: `save_connection` MCP tool
**Files:**
- Create: `spark_executor/tools/connections.py`
- Test: `tests/unit/test_connection_tools.py::test_save_connection`
**Interfaces:**
- Produces:
- `def save_connection(*, name: str, master: str, deploy_mode: str = "cluster", yarn_rm_url: str | None = None, spark_conf: dict[str, str] | None = None) -> dict[str, str]` — returns `{"name": name, "status": "SAVED"}`. Upserts: re-saving with the same `name` overwrites.
- [ ] **Step 1: Write the failing test (this file is shared by all four connection tools)**
`tests/unit/test_connection_tools.py`:
```python
# coding=utf-8
from pathlib import Path
import pytest
from spark_executor.core import connection_store
from spark_executor.tools import connections
@pytest.fixture(autouse=True)
def _fresh_store(tmp_path: Path, monkeypatch):
monkeypatch.setattr(connection_store, "DEFAULT_DATA_DIR", str(tmp_path))
monkeypatch.setattr(connection_store, "store", connection_store.ConnectionStore())
monkeypatch.setattr(connections, "store", connection_store.store)
return connection_store.store
def test_save_connection(_fresh_store):
out = connections.save_connection(name="prod", master="yarn")
assert out == {"name": "prod", "status": "SAVED"}
assert _fresh_store.get("prod") is not None
def test_save_connection_upserts(_fresh_store):
connections.save_connection(name="prod", master="yarn")
connections.save_connection(name="prod", master="spark://new:7077", deploy_mode="client")
assert _fresh_store.get("prod").master == "spark://new:7077"
assert _fresh_store.get("prod").deploy_mode == "client"
def test_save_connection_with_spark_conf(_fresh_store):
connections.save_connection(
name="dev",
master="yarn",
spark_conf={"spark.sql.shuffle.partitions": "200"},
)
assert _fresh_store.get("dev").spark_conf["spark.sql.shuffle.partitions"] == "200"
```
- [ ] **Step 2: Run the new tests to verify they fail**
```bash
uv run pytest tests/unit/test_connection_tools.py::test_save_connection -v
```
Expected: `ModuleNotFoundError: No module named 'spark_executor.tools.connections'`.
- [ ] **Step 3: Implement `connections.py`**
```python
# coding=utf-8
"""
@Time :2026/6/24
@Author :tao.chen
"""
from common.logging import logger
from spark_executor.core.connection_store import ConnectionStore, store
from spark_executor.models import Connection
def save_connection(
*,
name: str,
master: str,
deploy_mode: str = "cluster",
yarn_rm_url: str | None = None,
spark_conf: dict[str, str] | None = None,
) -> dict[str, str]:
conn = Connection(
name=name,
master=master,
deploy_mode=deploy_mode,
yarn_rm_url=yarn_rm_url,
spark_conf=spark_conf or {},
)
store.save(conn)
logger.info(f"save_connection name={name} master={master}")
return {"name": name, "status": "SAVED"}
def list_connections() -> list[dict[str, object]]:
return [c.model_dump() for c in store.list_all()]
def get_connection(name: str) -> dict[str, object]:
conn = store.get(name)
if conn is None:
raise KeyError(f"Unknown connection: {name}")
return conn.model_dump()
def delete_connection(name: str) -> dict[str, str]:
removed = store.delete(name)
if not removed:
raise KeyError(f"Unknown connection: {name}")
logger.info(f"delete_connection name={name}")
return {"name": name, "status": "DELETED"}
```
- [ ] **Step 4: Run the save tests to verify they pass**
```bash
uv run pytest tests/unit/test_connection_tools.py -v -k save_connection
```
Expected: 3 passed (the 3 `test_save_connection_*` cases). The other tests (list/get/delete) will fail because the implementation file already contains their functions — that is fine, they are filled in by Tasks 8–10 below.
- [ ] **Step 5: Commit**
```bash
git add spark_executor/tools/connections.py tests/unit/test_connection_tools.py
git commit -m "feat: add save_connection tool"
```
---
### Task 8: `list_connections` MCP tool
**Files:**
- Modify: `tests/unit/test_connection_tools.py` (append test case)
- Test: `tests/unit/test_connection_tools.py::test_list_connections`
- [ ] **Step 1: Append the failing test to `tests/unit/test_connection_tools.py`**
Add at the end of the file:
```python
def test_list_connections_empty(_fresh_store):
assert connections.list_connections() == []
def test_list_connections_returns_all_saved(_fresh_store):
connections.save_connection(name="a", master="yarn")
connections.save_connection(name="b", master="spark://m:7077", deploy_mode="client")
out = connections.list_connections()
names = {c["name"] for c in out}
assert names == {"a", "b"}
masters = {c["name"]: c["master"] for c in out}
assert masters == {"a": "yarn", "b": "spark://m:7077"}
```
- [ ] **Step 2: Run the new tests to verify they pass**
```bash
uv run pytest tests/unit/test_connection_tools.py -v -k list_connections
```
Expected: 2 passed. (`list_connections` was already implemented in Task 7 Step 3 as part of the same module.)
- [ ] **Step 3: Commit**
```bash
git add tests/unit/test_connection_tools.py
git commit -m "test: cover list_connections"
```
---
### Task 9: `get_connection` MCP tool
**Files:**
- Modify: `tests/unit/test_connection_tools.py` (append test case)
- [ ] **Step 1: Append the failing test to `tests/unit/test_connection_tools.py`**
```python
def test_get_connection_returns_dict(_fresh_store):
connections.save_connection(name="prod", master="yarn", deploy_mode="cluster")
out = connections.get_connection("prod")
assert out["name"] == "prod"
assert out["master"] == "yarn"
assert out["deploy_mode"] == "cluster"
assert out["yarn_rm_url"] is None
assert out["spark_conf"] == {}
def test_get_connection_unknown_raises(_fresh_store):
with pytest.raises(KeyError):
connections.get_connection("missing")
```
- [ ] **Step 2: Run the new tests to verify they pass**
```bash
uv run pytest tests/unit/test_connection_tools.py -v -k get_connection
```
Expected: 2 passed.
- [ ] **Step 3: Commit**
```bash
git add tests/unit/test_connection_tools.py
git commit -m "test: cover get_connection"
```
---
### Task 10: `delete_connection` MCP tool
**Files:**
- Modify: `tests/unit/test_connection_tools.py` (append test case)
- [ ] **Step 1: Append the failing test to `tests/unit/test_connection_tools.py`**
```python
def test_delete_connection_returns_status(_fresh_store):
connections.save_connection(name="prod", master="yarn")
assert connections.delete_connection("prod") == {"name": "prod", "status": "DELETED"}
assert _fresh_store.get("prod") is None
def test_delete_connection_unknown_raises(_fresh_store):
with pytest.raises(KeyError):
connections.delete_connection("missing")
```
- [ ] **Step 2: Run the new tests to verify they pass**
```bash
uv run pytest tests/unit/test_connection_tools.py -v -k delete_connection
```
Expected: 2 passed.
- [ ] **Step 3: Commit**
```bash
git add tests/unit/test_connection_tools.py
git commit -m "test: cover delete_connection"
```
---
### Task 11: `pending_store` — JSON-backed store for `PendingSubmission` records
Submission is a two-step flow: `prepare_submit_job` writes a `PendingSubmission` here, the user reviews and confirms, and only then does `confirm_submit_job` actually invoke `spark-submit`. The pending store sits in `./data/pending_jobs.json` (same `data_dir` as `connection_store`).
**Files:**
- Create: `spark_executor/core/pending_store.py`
- Test: `tests/unit/test_pending_store.py`
**Interfaces:**
- Produces:
- `class PendingStore` with methods:
- `list_all() -> list[PendingSubmission]`
- `get(pending_id: str) -> PendingSubmission | None`
- `save(pending: PendingSubmission) -> None` — upsert
- `delete(pending_id: str) -> bool`
- Module-level singleton `store: PendingStore`, defaulting to `/pending_jobs.json`.
- [ ] **Step 1: Write the failing test**
`tests/unit/test_pending_store.py`:
```python
# coding=utf-8
from datetime import datetime
from pathlib import Path
import pytest
from spark_executor.core import pending_store
from spark_executor.models import PendingSubmission
def _pending(pid: str = "p_1") -> PendingSubmission:
return PendingSubmission(
pending_id=pid,
connection="prod",
master="yarn",
deploy_mode="cluster",
script_path="/tmp/j.py",
queue="default",
executor_memory="2G",
executor_cores=2,
num_executors=2,
spark_conf={},
created_at=datetime(2026, 6, 24),
)
@pytest.fixture(autouse=True)
def _fresh(tmp_path: Path, monkeypatch):
monkeypatch.setattr(pending_store, "DEFAULT_DATA_DIR", str(tmp_path))
monkeypatch.setattr(pending_store, "store", pending_store.PendingStore())
def test_save_then_get_roundtrip():
pending_store.store.save(_pending())
out = pending_store.store.get("p_1")
assert out is not None
assert out.connection == "prod"
assert out.master == "yarn"
def test_save_persists_to_json(tmp_path: Path):
pending_store.store.save(_pending())
path = tmp_path / "pending_jobs.json"
assert path.is_file()
def test_get_missing_returns_none():
assert pending_store.store.get("missing") is None
def test_list_all_empty_when_file_absent():
assert pending_store.store.list_all() == []
def test_list_all_returns_saved():
pending_store.store.save(_pending("a"))
pending_store.store.save(_pending("b"))
assert {p.pending_id for p in pending_store.store.list_all()} == {"a", "b"}
def test_save_upserts_existing():
pending_store.store.save(_pending("a"))
updated = _pending("a")
updated.status = "SUBMITTED"
updated.job_id = "j_1"
pending_store.store.save(updated)
assert pending_store.store.get("a").status == "SUBMITTED"
assert pending_store.store.get("a").job_id == "j_1"
def test_delete_returns_true_when_present():
pending_store.store.save(_pending())
assert pending_store.store.delete("p_1") is True
assert pending_store.store.get("p_1") is None
def test_delete_returns_false_when_absent():
assert pending_store.store.delete("nope") is False
```
- [ ] **Step 2: Run test to verify it fails**
```bash
uv run pytest tests/unit/test_pending_store.py -v
```
Expected: `ModuleNotFoundError: No module named 'spark_executor.core.pending_store'`.
- [ ] **Step 3: Implement `pending_store.py`**
`pending_store.py` is structurally identical to `connection_store.py` (Task 6) but operates on `PendingSubmission` records and a different file. Reuse the same atomic-write pattern.
```python
# coding=utf-8
"""
@Time :2026/6/24
@Author :tao.chen
"""
import json
import os
import tempfile
from pathlib import Path
from threading import Lock
from spark_executor.models import PendingSubmission
DEFAULT_DATA_DIR = os.environ.get("SPARK_EXECUTOR_DATA_DIR", "./data")
DEFAULT_FILE_NAME = "pending_jobs.json"
class PendingStore:
"""JSON-backed CRUD for PendingSubmission records.
File path: /.
Atomic writes (tempfile + os.replace), same as ConnectionStore.
"""
def __init__(self, data_dir: str | None = None, file_name: str = DEFAULT_FILE_NAME) -> None:
self._data_dir = data_dir or DEFAULT_DATA_DIR
self._file_name = file_name
self._lock = Lock()
@property
def path(self) -> Path:
return Path(self._data_dir) / self._file_name
def _load(self) -> dict[str, PendingSubmission]:
if not self.path.exists():
return {}
raw = json.loads(self.path.read_text(encoding="utf-8"))
return {pid: PendingSubmission.model_validate(p) for pid, p in raw.items()}
def _dump(self, records: dict[str, PendingSubmission]) -> None:
os.makedirs(self._data_dir, exist_ok=True)
payload = {pid: p.model_dump() for pid, p in records.items()}
fd, tmp_path = tempfile.mkstemp(
prefix=self._file_name + ".", dir=self._data_dir
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2, ensure_ascii=False, default=str)
os.replace(tmp_path, self.path)
except Exception:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
raise
def list_all(self) -> list[PendingSubmission]:
with self._lock:
return list(self._load().values())
def get(self, pending_id: str) -> PendingSubmission | None:
with self._lock:
return self._load().get(pending_id)
def save(self, pending: PendingSubmission) -> None:
with self._lock:
records = self._load()
records[pending.pending_id] = pending
self._dump(records)
def delete(self, pending_id: str) -> bool:
with self._lock:
records = self._load()
if pending_id not in records:
return False
del records[pending_id]
self._dump(records)
return True
# Module-level singleton; replaced in tests.
store = PendingStore()
```
Note: `default=str` is required on `json.dump` because `created_at` is a `datetime`.
- [ ] **Step 4: Run test to verify it passes**
```bash
uv run pytest tests/unit/test_pending_store.py -v
```
Expected: 8 passed.
- [ ] **Step 5: Commit**
```bash
git add spark_executor/core/pending_store.py tests/unit/test_pending_store.py
git commit -m "feat: add JSON-backed PendingStore"
```
---
### Task 12: `prepare_submit_job` MCP tool — record params, do NOT submit
`prepare_submit_job` is the *first half* of the submit flow. It snapshots the connection's master / deploy_mode / spark_conf into a `PendingSubmission`, persists it, and returns the `pending_id`. **No `spark-submit` is invoked.**
**Files:**
- Create: `spark_executor/tools/submit.py` (this replaces the direct-submit tool that previously lived here)
- Create: `tests/unit/test_submit_tool.py` (this file is fresh — the old direct-submit tests are removed)
**Interfaces:**
- Produces:
- `def prepare_submit_job(*, connection: str, script_path: str, queue: str = "default", executor_memory: str = "2G", executor_cores: int = 2, num_executors: int = 2) -> dict[str, object]` — returns `{"pending_id": ..., "status": "PENDING", "parameters": {...}}`. The full `PendingSubmission` (minus pending_id, created_at, status) is echoed back in `parameters` so the user (or agent) can review exactly what will be submitted. Raises `KeyError` if `connection` is unknown.
- Module-level `pending_store` (singleton) imported from `spark_executor.core.pending_store`.
- [ ] **Step 1: Write the failing test**
`tests/unit/test_submit_tool.py`:
```python
# coding=utf-8
from pathlib import Path
from unittest.mock import patch
import pytest
from spark_executor.core import connection_store, pending_store
from spark_executor.core.pending_store import PendingStore
from spark_executor.models import Connection
from spark_executor.tools import submit
@pytest.fixture(autouse=True)
def _fresh(tmp_path: Path, monkeypatch):
monkeypatch.setattr(connection_store, "DEFAULT_DATA_DIR", str(tmp_path))
monkeypatch.setattr(connection_store, "store", connection_store.ConnectionStore())
monkeypatch.setattr(pending_store, "DEFAULT_DATA_DIR", str(tmp_path))
monkeypatch.setattr(pending_store, "store", PendingStore())
submit.pending_store = pending_store.store
connection_store.store.save(Connection(name="prod", master="yarn", deploy_mode="cluster"))
def _last_pending_id() -> str:
pending = submit.pending_store.list_all()
assert pending, "no pending submission was created"
return pending[-1].pending_id
def test_prepare_does_not_invoke_spark_submit(monkeypatch):
called = []
monkeypatch.setattr(submit, "run_spark_submit", lambda cmd: called.append(cmd))
out = submit.prepare_submit_job(connection="prod", script_path="/tmp/j.py")
assert called == [] # never called
assert out["status"] == "PENDING"
assert out["pending_id"].startswith("p_")
def test_prepare_persists_pending_with_snapshot(monkeypatch):
monkeypatch.setattr(submit, "run_spark_submit", lambda cmd: None)
connection_store.store.save(
Connection(
name="prod",
master="yarn",
deploy_mode="cluster",
spark_conf={"spark.sql.shuffle.partitions": "200"},
)
)
submit.prepare_submit_job(connection="prod", script_path="/tmp/j.py", queue="research")
p = submit.pending_store.get(_last_pending_id())
assert p is not None
assert p.connection == "prod"
assert p.master == "yarn"
assert p.deploy_mode == "cluster"
assert p.spark_conf == {"spark.sql.shuffle.partitions": "200"}
assert p.queue == "research"
assert p.script_path == "/tmp/j.py"
def test_prepare_raises_for_unknown_connection():
with pytest.raises(KeyError, match="missing"):
submit.prepare_submit_job(connection="missing", script_path="/tmp/j.py")
def test_prepare_snapshots_connection_at_prepare_time(monkeypatch):
"""Editing the connection between prepare and confirm must NOT silently retarget."""
monkeypatch.setattr(submit, "run_spark_submit", lambda cmd: None)
submit.prepare_submit_job(connection="prod", script_path="/tmp/j.py")
p = submit.pending_store.get(_last_pending_id())
# Now mutate the connection
connection_store.store.save(
Connection(name="prod", master="spark://attacker:7077", deploy_mode="client")
)
# Snapshot is unchanged
p2 = submit.pending_store.get(p.pending_id)
assert p2.master == "yarn"
assert p2.deploy_mode == "cluster"
```
- [ ] **Step 2: Run test to verify it fails**
```bash
uv run pytest tests/unit/test_submit_tool.py -v
```
Expected: `ModuleNotFoundError: No module named 'spark_executor.tools.submit'`.
- [ ] **Step 3: Implement `submit.py`**
```python
# coding=utf-8
"""
@Time :2026/6/24
@Author :tao.chen
"""
import secrets
from datetime import datetime
from common.logging import logger
from spark_executor.core.connection_store import store as conn_store
from spark_executor.core.pending_store import store as pending_store
from spark_executor.models import PendingSubmission
def _new_pending_id() -> str:
return "p_" + secrets.token_hex(6)
def prepare_submit_job(
*,
connection: str,
script_path: str,
queue: str = "default",
executor_memory: str = "2G",
executor_cores: int = 2,
num_executors: int = 2,
) -> dict[str, object]:
"""Snapshot connection params and persist a PendingSubmission. Does NOT submit."""
conn = conn_store.get(connection)
if conn is None:
raise KeyError(f"Unknown connection: {connection}")
pending_id = _new_pending_id()
pending = PendingSubmission(
pending_id=pending_id,
connection=connection,
master=conn.master,
deploy_mode=conn.deploy_mode,
script_path=script_path,
queue=queue,
executor_memory=executor_memory,
executor_cores=executor_cores,
num_executors=num_executors,
spark_conf=dict(conn.spark_conf),
created_at=datetime.utcnow(),
status="PENDING",
)
pending_store.save(pending)
logger.info(
f"prepare_submit_job pending_id={pending_id} connection={connection} master={conn.master}"
)
return {
"pending_id": pending_id,
"status": "PENDING",
"parameters": pending.model_dump(),
}
```
- [ ] **Step 4: Run test to verify it passes**
```bash
uv run pytest tests/unit/test_submit_tool.py -v
```
Expected: 4 passed.
- [ ] **Step 5: Commit**
```bash
git add spark_executor/tools/submit.py tests/unit/test_submit_tool.py
git commit -m "feat: add prepare_submit_job (no spark-submit invocation)"
```
---
### Task 13: `confirm_submit_job` MCP tool — actually run `spark-submit`
`confirm_submit_job` is the *second half* of the submit flow. It loads a `PendingSubmission` by `pending_id`, builds the spark-submit command from the **snapshot** (not the live connection), runs it, creates the `Job`, and marks the pending entry as `SUBMITTED` (or `FAILED`).
**Files:**
- Modify: `spark_executor/tools/submit.py` (append `confirm_submit_job`)
- Modify: `tests/unit/test_submit_tool.py` (append tests)
**Interfaces:**
- Produces:
- `def confirm_submit_job(*, pending_id: str) -> SubmitResult` — raises `KeyError` if `pending_id` unknown. Raises `ValueError` if the pending is not in `PENDING` status. On `SparkSubmitError`, the pending is marked `FAILED` with the error and re-raised.
- [ ] **Step 1: Append the failing tests to `tests/unit/test_submit_tool.py`**
```python
def test_confirm_invokes_spark_submit_and_marks_submitted(monkeypatch):
submit.prepare_submit_job(connection="prod", script_path="/tmp/j.py")
pid = _last_pending_id()
fake_proc = type("P", (), {
"returncode": 0,
"stderr": "tracking URL: http://rm:8088/proxy/application_17400000001/\n",
})()
with patch("spark_executor.tools.submit.run_spark_submit", return_value=fake_proc) as m:
result = submit.confirm_submit_job(pending_id=pid)
cmd = m.call_args.args[0]
assert "yarn" in cmd
assert "cluster" in cmd
assert cmd[-1] == "/tmp/j.py"
assert result.application_id == "application_17400000001"
# pending updated
p = submit.pending_store.get(pid)
assert p.status == "SUBMITTED"
assert p.application_id == "application_17400000001"
assert p.job_id is not None
def test_confirm_raises_for_unknown_pending_id():
with pytest.raises(KeyError, match="missing"):
submit.confirm_submit_job(pending_id="missing")
def test_confirm_refuses_non_pending_status(monkeypatch):
submit.prepare_submit_job(connection="prod", script_path="/tmp/j.py")
pid = _last_pending_id()
# Mark it CANCELLED first
p = submit.pending_store.get(pid)
p.status = "CANCELLED"
submit.pending_store.save(p)
with pytest.raises(ValueError, match="CANCELLED"):
submit.confirm_submit_job(pending_id=pid)
def test_confirm_marks_failed_on_spark_submit_error(monkeypatch):
from spark_executor.core.spark_submit import SparkSubmitError
submit.prepare_submit_job(connection="prod", script_path="/tmp/j.py")
pid = _last_pending_id()
def _raise(_cmd):
raise SparkSubmitError("boom")
monkeypatch.setattr(submit, "run_spark_submit", _raise)
with pytest.raises(SparkSubmitError):
submit.confirm_submit_job(pending_id=pid)
p = submit.pending_store.get(pid)
assert p.status == "FAILED"
assert "boom" in (p.error or "")
```
- [ ] **Step 2: Run the new tests to verify they fail**
```bash
uv run pytest tests/unit/test_submit_tool.py -v -k confirm
```
Expected: `AttributeError: module 'spark_executor.tools.submit' has no attribute 'confirm_submit_job'`.
- [ ] **Step 3: Append `confirm_submit_job` to `submit.py`**
```python
import uuid
from datetime import datetime as _datetime
from spark_executor.core.job_store import JobStore
from spark_executor.core.log_parser import parse_spark_submit_output
from spark_executor.core.spark_submit import (
SparkSubmitError,
build_spark_submit_command,
run_spark_submit,
)
from spark_executor.models import Job, SubmitResult
# Module-level job store singleton; replaced in tests.
job_store: JobStore = JobStore()
def confirm_submit_job(*, pending_id: str) -> SubmitResult:
"""Actually invoke spark-submit for a previously-prepared PendingSubmission."""
pending = pending_store.get(pending_id)
if pending is None:
raise KeyError(f"Unknown pending_id: {pending_id}")
if pending.status != "PENDING":
raise ValueError(
f"pending_id {pending_id} is in status {pending.status!r}, not PENDING"
)
cmd = build_spark_submit_command(
master=pending.master,
deploy_mode=pending.deploy_mode,
script_path=pending.script_path,
queue=pending.queue,
executor_memory=pending.executor_memory,
executor_cores=pending.executor_cores,
num_executors=pending.num_executors,
spark_conf=pending.spark_conf,
)
logger.info(f"confirm_submit_job pending_id={pending_id} cmd={cmd}")
try:
result = run_spark_submit(cmd)
except SparkSubmitError as exc:
pending.status = "FAILED"
pending.error = str(exc)
pending_store.save(pending)
raise
application_id, tracking_url = parse_spark_submit_output(result.stderr)
job_id = uuid.uuid4().hex[:12]
job_store.put(
Job(
job_id=job_id,
application_id=application_id,
script_path=pending.script_path,
queue=pending.queue,
submit_time=_datetime.utcnow(),
connection=pending.connection,
)
)
pending.status = "SUBMITTED"
pending.job_id = job_id
pending.application_id = application_id
pending_store.save(pending)
logger.info(
f"confirm_submit_job pending_id={pending_id} job_id={job_id} application_id={application_id}"
)
return SubmitResult(
job_id=job_id,
application_id=application_id,
tracking_url=tracking_url,
)
```
- [ ] **Step 4: Run test to verify it passes**
```bash
uv run pytest tests/unit/test_submit_tool.py -v
```
Expected: 8 passed (4 prepare + 4 confirm).
- [ ] **Step 5: Commit**
```bash
git add spark_executor/tools/submit.py tests/unit/test_submit_tool.py
git commit -m "feat: add confirm_submit_job (two-step submit flow)"
```
---
### Task 14: `list_pending_jobs` MCP tool
**Files:**
- Modify: `spark_executor/tools/submit.py` (append `list_pending_jobs`)
- Modify: `tests/unit/test_submit_tool.py` (append tests)
**Interfaces:**
- Produces:
- `def list_pending_jobs() -> list[dict[str, object]]` — returns the model dump of every `PendingSubmission` in the store.
- [ ] **Step 1: Append the failing test to `tests/unit/test_submit_tool.py`**
```python
def test_list_pending_jobs_empty():
assert submit.list_pending_jobs() == []
def test_list_pending_jobs_returns_all(monkeypatch):
monkeypatch.setattr(submit, "run_spark_submit", lambda cmd: None)
submit.prepare_submit_job(connection="prod", script_path="/tmp/a.py")
submit.prepare_submit_job(connection="prod", script_path="/tmp/b.py")
out = submit.list_pending_jobs()
assert {p["script_path"] for p in out} == {"/tmp/a.py", "/tmp/b.py"}
assert all(p["status"] == "PENDING" for p in out)
```
- [ ] **Step 2: Run the new tests to verify they fail**
```bash
uv run pytest tests/unit/test_submit_tool.py -v -k list_pending
```
Expected: `AttributeError: module 'spark_executor.tools.submit' has no attribute 'list_pending_jobs'`.
- [ ] **Step 3: Append `list_pending_jobs` to `submit.py`**
```python
def list_pending_jobs() -> list[dict[str, object]]:
return [p.model_dump() for p in pending_store.list_all()]
```
- [ ] **Step 4: Run test to verify it passes**
```bash
uv run pytest tests/unit/test_submit_tool.py -v -k list_pending
```
Expected: 2 passed.
- [ ] **Step 5: Commit**
```bash
git add spark_executor/tools/submit.py tests/unit/test_submit_tool.py
git commit -m "feat: add list_pending_jobs"
```
---
### Task 15: `get_pending_job` MCP tool
**Files:**
- Modify: `spark_executor/tools/submit.py` (append `get_pending_job`)
- Modify: `tests/unit/test_submit_tool.py` (append tests)
**Interfaces:**
- Produces:
- `def get_pending_job(pending_id: str) -> dict[str, object]` — returns the model dump. Raises `KeyError` if unknown.
- [ ] **Step 1: Append the failing test to `tests/unit/test_submit_tool.py`**
```python
def test_get_pending_job_returns_dump(monkeypatch):
monkeypatch.setattr(submit, "run_spark_submit", lambda cmd: None)
submit.prepare_submit_job(connection="prod", script_path="/tmp/j.py")
pid = _last_pending_id()
out = submit.get_pending_job(pid)
assert out["pending_id"] == pid
assert out["connection"] == "prod"
assert out["status"] == "PENDING"
def test_get_pending_job_unknown_raises():
with pytest.raises(KeyError):
submit.get_pending_job("missing")
```
- [ ] **Step 2: Run the new tests to verify they fail**
```bash
uv run pytest tests/unit/test_submit_tool.py -v -k get_pending
```
Expected: `AttributeError: module 'spark_executor.tools.submit' has no attribute 'get_pending_job'`.
- [ ] **Step 3: Append `get_pending_job` to `submit.py`**
```python
def get_pending_job(pending_id: str) -> dict[str, object]:
p = pending_store.get(pending_id)
if p is None:
raise KeyError(f"Unknown pending_id: {pending_id}")
return p.model_dump()
```
- [ ] **Step 4: Run test to verify it passes**
```bash
uv run pytest tests/unit/test_submit_tool.py -v -k get_pending
```
Expected: 2 passed.
- [ ] **Step 5: Commit**
```bash
git add spark_executor/tools/submit.py tests/unit/test_submit_tool.py
git commit -m "feat: add get_pending_job"
```
---
### Task 16: `cancel_pending_job` MCP tool
**Files:**
- Modify: `spark_executor/tools/submit.py` (append `cancel_pending_job`)
- Modify: `tests/unit/test_submit_tool.py` (append tests)
**Interfaces:**
- Produces:
- `def cancel_pending_job(pending_id: str) -> dict[str, str]` — flips the entry's status to `CANCELLED` (the record is kept for audit). Refuses to cancel a record that is already `SUBMITTED` or `FAILED` (raises `ValueError`). Unknown id raises `KeyError`.
- [ ] **Step 1: Append the failing test to `tests/unit/test_submit_tool.py`**
```python
def test_cancel_pending_job_marks_cancelled(monkeypatch):
monkeypatch.setattr(submit, "run_spark_submit", lambda cmd: None)
submit.prepare_submit_job(connection="prod", script_path="/tmp/j.py")
pid = _last_pending_id()
out = submit.cancel_pending_job(pid)
assert out == {"pending_id": pid, "status": "CANCELLED"}
assert submit.pending_store.get(pid).status == "CANCELLED"
def test_cancel_pending_job_unknown_raises():
with pytest.raises(KeyError):
submit.cancel_pending_job("missing")
def test_cancel_pending_job_refuses_submitted(monkeypatch):
submit.prepare_submit_job(connection="prod", script_path="/tmp/j.py")
pid = _last_pending_id()
p = submit.pending_store.get(pid)
p.status = "SUBMITTED"
submit.pending_store.save(p)
with pytest.raises(ValueError, match="SUBMITTED"):
submit.cancel_pending_job(pid)
```
- [ ] **Step 2: Run the new tests to verify they fail**
```bash
uv run pytest tests/unit/test_submit_tool.py -v -k cancel_pending
```
Expected: `AttributeError: module 'spark_executor.tools.submit' has no attribute 'cancel_pending_job'`.
- [ ] **Step 3: Append `cancel_pending_job` to `submit.py`**
```python
def cancel_pending_job(pending_id: str) -> dict[str, str]:
p = pending_store.get(pending_id)
if p is None:
raise KeyError(f"Unknown pending_id: {pending_id}")
if p.status in ("SUBMITTED", "FAILED"):
raise ValueError(
f"pending_id {pending_id} is in status {p.status!r} and cannot be cancelled"
)
p.status = "CANCELLED"
pending_store.save(p)
logger.info(f"cancel_pending_job pending_id={pending_id}")
return {"pending_id": pending_id, "status": "CANCELLED"}
```
- [ ] **Step 4: Run test to verify it passes**
```bash
uv run pytest tests/unit/test_submit_tool.py -v -k cancel_pending
```
Expected: 3 passed.
- [ ] **Step 5: Commit**
```bash
git add spark_executor/tools/submit.py tests/unit/test_submit_tool.py
git commit -m "feat: add cancel_pending_job"
```
---
### Task 17: `get_job_status` MCP tool
**Files:**
- Create: `spark_executor/tools/status.py`
- Test: `tests/unit/test_status_tool.py`
**Interfaces:**
- Produces:
- `def get_job_status(job_id: str) -> JobStatus` — looks up the job in the store to get `application_id`, then calls `get_application_status`. Raises `KeyError` if `job_id` unknown.
- [ ] **Step 1: Write the failing test**
`tests/unit/test_status_tool.py`:
```python
# coding=utf-8
from datetime import datetime
from unittest.mock import patch
from spark_executor.core.job_store import JobStore
from spark_executor.models import Job
from spark_executor.tools import status
def test_get_job_status_returns_state():
status.store = JobStore()
status.store.put(
Job(
job_id="abc",
application_id="application_1",
script_path="/tmp/j.py",
queue="default",
submit_time=datetime(2026, 6, 24),
connection="prod",
)
)
with patch(
"spark_executor.tools.status.get_application_status",
return_value=("RUNNING", "State : RUNNING\n"),
):
out = status.get_job_status("abc")
assert out.application_id == "application_1"
assert out.state == "RUNNING"
assert "RUNNING" in out.raw
def test_get_job_status_raises_for_unknown_job():
status.store = JobStore()
import pytest
with pytest.raises(KeyError):
status.get_job_status("missing")
```
- [ ] **Step 2: Run test to verify it fails**
```bash
uv run pytest tests/unit/test_status_tool.py -v
```
Expected: `ModuleNotFoundError: No module named 'spark_executor.tools.status'`.
- [ ] **Step 3: Implement `status.py`**
```python
# coding=utf-8
"""
@Time :2026/6/24
@Author :tao.chen
"""
from common.logging import logger
from spark_executor.core.job_store import JobStore
from spark_executor.core.yarn_client import get_application_status
from spark_executor.models import JobStatus
store = JobStore()
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)
logger.info(f"get_job_status job_id={job_id} state={state}")
return JobStatus(application_id=job.application_id, state=state, raw=raw)
```
- [ ] **Step 4: Run test to verify it passes**
```bash
uv run pytest tests/unit/test_status_tool.py -v
```
Expected: 2 passed.
- [ ] **Step 5: Commit**
```bash
git add spark_executor/tools/status.py tests/unit/test_status_tool.py
git commit -m "feat: add get_job_status tool"
```
---
### Task 18: `get_job_logs` MCP tool (tail to 5000 chars)
**Files:**
- Create: `spark_executor/tools/logs.py`
- Test: `tests/unit/test_logs_tool.py`
**Interfaces:**
- Produces:
- `def get_job_logs(job_id: str, tail_chars: int = 5000) -> str` — looks up `application_id` from the store, calls `get_application_logs`, returns the last `tail_chars` characters (default 5000). Raises `KeyError` if `job_id` unknown.
- [ ] **Step 1: Write the failing test**
`tests/unit/test_logs_tool.py`:
```python
# coding=utf-8
from datetime import datetime
from unittest.mock import patch
from spark_executor.core.job_store import JobStore
from spark_executor.models import Job
from spark_executor.tools import logs
def _seed(job_id="abc", app_id="application_1"):
logs.store = JobStore()
logs.store.put(
Job(
job_id=job_id,
application_id=app_id,
script_path="/tmp/j.py",
queue="default",
submit_time=datetime(2026, 6, 24),
connection="prod",
)
)
def test_get_job_logs_tails_to_default_5000():
_seed()
big = "x" * 8000 + "\nEND"
with patch("spark_executor.tools.logs.get_application_logs", return_value=big):
out = logs.get_job_logs("abc")
assert out.endswith("END")
assert len(out) == 5000
def test_get_job_logs_respects_custom_tail_chars():
_seed()
with patch(
"spark_executor.tools.logs.get_application_logs",
return_value="0123456789",
):
out = logs.get_job_logs("abc", tail_chars=3)
assert out == "789"
def test_get_job_logs_raises_for_unknown_job():
_seed()
import pytest
with pytest.raises(KeyError):
logs.get_job_logs("missing")
```
- [ ] **Step 2: Run test to verify it fails**
```bash
uv run pytest tests/unit/test_logs_tool.py -v
```
Expected: `ModuleNotFoundError: No module named 'spark_executor.tools.logs'`.
- [ ] **Step 3: Implement `logs.py`**
```python
# coding=utf-8
"""
@Time :2026/6/24
@Author :tao.chen
"""
from common.logging import logger
from spark_executor.core.job_store import JobStore
from spark_executor.core.yarn_client import get_application_logs
store = JobStore()
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)
tailed = full[-tail_chars:] if len(full) > tail_chars else full
logger.info(
f"get_job_logs job_id={job_id} application_id={job.application_id} "
f"chars={len(tailed)}"
)
return tailed
```
- [ ] **Step 4: Run test to verify it passes**
```bash
uv run pytest tests/unit/test_logs_tool.py -v
```
Expected: 3 passed.
- [ ] **Step 5: Commit**
```bash
git add spark_executor/tools/logs.py tests/unit/test_logs_tool.py
git commit -m "feat: add get_job_logs tool with tail"
```
---
### Task 19: `kill_job` MCP tool
**Files:**
- Create: `spark_executor/tools/kill.py`
- Test: `tests/unit/test_kill_tool.py`
**Interfaces:**
- Produces:
- `def kill_job(job_id: str) -> dict[str, str]` — returns `{"job_id": ..., "application_id": ..., "status": "KILLED"}`. Raises `KeyError` if `job_id` unknown.
- [ ] **Step 1: Write the failing test**
`tests/unit/test_kill_tool.py`:
```python
# coding=utf-8
from datetime import datetime
from unittest.mock import patch
from spark_executor.core.job_store import JobStore
from spark_executor.models import Job
from spark_executor.tools import kill
def test_kill_job_calls_yarn_kill():
kill.store = JobStore()
kill.store.put(
Job(
job_id="abc",
application_id="application_1",
script_path="/tmp/j.py",
queue="default",
submit_time=datetime(2026, 6, 24),
connection="prod",
)
)
with patch("spark_executor.tools.kill.kill_application") as m:
result = kill.kill_job("abc")
m.assert_called_once_with("application_1")
assert result == {
"job_id": "abc",
"application_id": "application_1",
"status": "KILLED",
}
def test_kill_job_raises_for_unknown_job():
kill.store = JobStore()
import pytest
with pytest.raises(KeyError):
kill.kill_job("missing")
```
- [ ] **Step 2: Run test to verify it fails**
```bash
uv run pytest tests/unit/test_kill_tool.py -v
```
Expected: `ModuleNotFoundError: No module named 'spark_executor.tools.kill'`.
- [ ] **Step 3: Implement `kill.py`**
```python
# coding=utf-8
"""
@Time :2026/6/24
@Author :tao.chen
"""
from common.logging import logger
from spark_executor.core.job_store import JobStore
from spark_executor.core.yarn_client import kill_application
store = JobStore()
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)
logger.info(f"kill_job job_id={job_id} application_id={job.application_id}")
return {
"job_id": job_id,
"application_id": job.application_id,
"status": "KILLED",
}
```
- [ ] **Step 4: Run test to verify it passes**
```bash
uv run pytest tests/unit/test_kill_tool.py -v
```
Expected: 2 passed.
- [ ] **Step 5: Commit**
```bash
git add spark_executor/tools/kill.py tests/unit/test_kill_tool.py
git commit -m "feat: add kill_job tool"
```
---
### Task 20: Wire all tools into the FastAPI app and verify MCP discovery
`fastapi-mcp` auto-discovers FastAPI routes and exposes them as MCP tools. We mount the twelve tool functions as POST routes on the existing `app` in `spark_executor/server.py`.
**Files:**
- Modify: `spark_executor/server.py`
- Test: `tests/integration/__init__.py` (empty header file)
- Test: `tests/integration/test_mcp_routes.py`
- [ ] **Step 1: Create integration test directory**
`tests/integration/__init__.py` (standard header, empty body).
- [ ] **Step 2: Write the integration test**
`tests/integration/test_mcp_routes.py`:
```python
# coding=utf-8
from fastapi.testclient import TestClient
from spark_executor.server import app
def test_health_still_present():
c = TestClient(app)
r = c.get("/health")
assert r.status_code == 200
assert r.json() == {"status": "ok"}
def test_twelve_tool_routes_registered():
paths = {r.path for r in app.routes}
for path in (
# pending-submission flow (5)
"/prepare_submit_job",
"/confirm_submit_job",
"/list_pending_jobs",
"/get_pending_job",
"/cancel_pending_job",
# job lifecycle (3)
"/get_job_status",
"/get_job_logs",
"/kill_job",
# connection management (4)
"/save_connection",
"/list_connections",
"/get_connection",
"/delete_connection",
):
assert path in paths, f"missing MCP tool route: {path}"
```
- [ ] **Step 3: Run test to verify it fails**
```bash
uv run pytest tests/integration/test_mcp_routes.py -v
```
Expected: `AssertionError: missing MCP tool route: /prepare_submit_job` (or similar — only `/health` is registered).
- [ ] **Step 4: Add the twelve tool routes to `server.py`**
Replace `spark_executor/server.py` with:
```python
# coding=utf-8
"""
@Time :2026/6/24
@Author :tao.chen
"""
from fastapi import FastAPI
from spark_executor.tools.connections import (
delete_connection,
get_connection,
list_connections,
save_connection,
)
from spark_executor.tools.kill import kill_job
from spark_executor.tools.logs import get_job_logs
from spark_executor.tools.status import get_job_status
from spark_executor.tools.submit import (
cancel_pending_job,
confirm_submit_job,
get_pending_job,
list_pending_jobs,
prepare_submit_job,
)
app = FastAPI(title="Spark Executor", version="0.0.1", description="Spark Executor MCP Server")
@app.get("/health")
def health_check():
return {"status": "ok"}
# MCP tool routes. fastapi-mcp discovers these and registers them as MCP tools.
# --- Pending submission flow (two-step submit) ---
@app.post("/prepare_submit_job")
def _prepare_submit_job(connection: str, script_path: str, queue: str = "default",
executor_memory: str = "2G", executor_cores: int = 2,
num_executors: int = 2):
return prepare_submit_job(
connection=connection,
script_path=script_path,
queue=queue,
executor_memory=executor_memory,
executor_cores=executor_cores,
num_executors=num_executors,
)
@app.post("/confirm_submit_job")
def _confirm_submit_job(pending_id: str):
return confirm_submit_job(pending_id=pending_id)
@app.post("/list_pending_jobs")
def _list_pending_jobs():
return list_pending_jobs()
@app.post("/get_pending_job")
def _get_pending_job(pending_id: str):
return get_pending_job(pending_id)
@app.post("/cancel_pending_job")
def _cancel_pending_job(pending_id: str):
return cancel_pending_job(pending_id)
# --- Spark job tools ---
@app.post("/get_job_status")
def _get_job_status(job_id: str):
return get_job_status(job_id)
@app.post("/get_job_logs")
def _get_job_logs(job_id: str, tail_chars: int = 5000):
return get_job_logs(job_id, tail_chars=tail_chars)
@app.post("/kill_job")
def _kill_job(job_id: str):
return kill_job(job_id)
# --- Connection management tools ---
@app.post("/save_connection")
def _save_connection(name: str, master: str, deploy_mode: str = "cluster",
yarn_rm_url: str | None = None,
spark_conf: dict[str, str] | None = None):
return save_connection(
name=name,
master=master,
deploy_mode=deploy_mode,
yarn_rm_url=yarn_rm_url,
spark_conf=spark_conf,
)
@app.post("/list_connections")
def _list_connections():
return list_connections()
@app.post("/get_connection")
def _get_connection(name: str):
return get_connection(name)
@app.post("/delete_connection")
def _delete_connection(name: str):
return delete_connection(name)
```
- [ ] **Step 5: Run integration test to verify it passes**
```bash
uv run pytest tests/integration/test_mcp_routes.py -v
```
Expected: 2 passed.
- [ ] **Step 6: Run the full suite to confirm no regressions**
```bash
uv run pytest -v
```
Expected: all unit + integration tests pass (8 + 4 + 5 + 5 + 3 + 8 + 9 + 8 + 15 + 2 + 3 + 2 + 2 = 74).
- [ ] **Step 7: Smoke-test the running server (manual)**
```bash
uv run main.py &
sleep 2
curl -s http://localhost:8000/health
curl -s -X POST http://localhost:8000/save_connection \
-H "Content-Type: application/json" \
-d '{"name":"prod","master":"yarn"}'
curl -s -X POST http://localhost:8000/prepare_submit_job \
-H "Content-Type: application/json" \
-d '{"connection":"prod","script_path":"/tmp/jobs/demo.py"}'
# (no submit happens; pending_id is returned)
curl -s -X POST http://localhost:8000/list_pending_jobs
curl -s -X POST http://localhost:8000/spark-executor-mcp/mcp -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
kill %1
```
Expected:
- `/health` returns `{"status":"ok"}`.
- `save_connection` returns `{"name":"prod","status":"SAVED"}`; `./data/connections.json` is created.
- `prepare_submit_job` returns a payload containing `pending_id` (prefix `p_`); `./data/pending_jobs.json` is created. **No `spark-submit` is invoked.**
- `list_pending_jobs` returns the prepared entry with `status: "PENDING"`.
- `tools/list` enumerates all 12 tools: `prepare_submit_job`, `confirm_submit_job`, `list_pending_jobs`, `get_pending_job`, `cancel_pending_job`, `get_job_status`, `get_job_logs`, `kill_job`, `save_connection`, `list_connections`, `get_connection`, `delete_connection`.
- [ ] **Step 8: Commit**
```bash
git add spark_executor/server.py tests/integration
git commit -m "feat: register 12 MCP tools (pending flow + job lifecycle + connections)"
```
---
### Stage 1 checkpoint
After Task 20 you have a working minimum-closed-loop MCP server with cluster-connection management and a two-step pending submission flow:
- `save_connection` / `list_connections` / `get_connection` / `delete_connection` — persist to `./data/connections.json`.
- `prepare_submit_job(connection, ...)` — snapshots the connection's master / deploy_mode / spark_conf into a `PendingSubmission`, writes `./data/pending_jobs.json`, returns `pending_id`. **Does NOT invoke `spark-submit`.**
- `list_pending_jobs` / `get_pending_job` — read pending entries.
- `cancel_pending_job` — flips a PENDING entry to CANCELLED.
- `confirm_submit_job(pending_id)` — only on user second confirmation: runs `spark-submit` with the snapshot, parses `application_id`, creates a `Job` in the in-memory `JobStore`, marks the pending entry `SUBMITTED`.
- `get_job_status` → `yarn application -status`, parses `State : ...`.
- `get_job_logs` → `yarn logs -applicationId ...`, tails to 5000 chars.
- `kill_job` → `yarn application -kill`.
The end-to-end agent loop is therefore reviewable:
```
prepare_submit_job(...) -> pending_id
confirm_submit_job(pending_id) -> job_id, application_id
get_job_status(job_id) -> RUNNING | SUCCEEDED | FAILED | KILLED
get_job_logs(job_id) -> tail of driver logs
kill_job(job_id) -> KILLED
```
Stop here and demo before continuing. Stage 2 is a small additive change; Stage 3 is a real refactor.
---
# Stage 2 — LLM-driven PySpark generation (★★☆☆☆)
Adds one tool, `write_job_file`, that takes a PySpark code string and writes it to a timestamped file under `/tmp/jobs/`. The Agent can then call `submit_job` on the path. No new infrastructure beyond one file.
---
### Task 21: `job_writer` — write PySpark code to disk
**Files:**
- Create: `spark_executor/core/job_writer.py`
- Test: `tests/unit/test_job_writer.py`
**Interfaces:**
- Produces:
- `def write_job_file(code: str, jobs_dir: str = "/tmp/jobs") -> str` — writes `code` to `/job__.py`, creates the directory if needed, returns the absolute path.
- [ ] **Step 1: Write the failing test**
`tests/unit/test_job_writer.py`:
```python
# coding=utf-8
import os
import tempfile
from pathlib import Path
from spark_executor.core.job_writer import write_job_file
def test_write_job_file_creates_file_with_code(tmp_path: Path):
out = write_job_file("print('hi')\n", jobs_dir=str(tmp_path))
assert os.path.isfile(out)
assert out.startswith(str(tmp_path))
assert out.endswith(".py")
with open(out) as f:
assert f.read() == "print('hi')\n"
def test_write_job_file_creates_jobs_dir_if_missing(tmp_path: Path):
target = tmp_path / "newdir"
out = write_job_file("x = 1\n", jobs_dir=str(target))
assert target.is_dir()
assert os.path.isfile(out)
```
- [ ] **Step 2: Run test to verify it fails**
```bash
uv run pytest tests/unit/test_job_writer.py -v
```
Expected: `ModuleNotFoundError: No module named 'spark_executor.core.job_writer'`.
- [ ] **Step 3: Implement `job_writer.py`**
```python
# coding=utf-8
"""
@Time :2026/6/24
@Author :tao.chen
"""
import os
import secrets
from datetime import datetime
def write_job_file(code: str, jobs_dir: str = "/tmp/jobs") -> str:
os.makedirs(jobs_dir, exist_ok=True)
stamp = datetime.utcnow().strftime("%Y%m%d%H%M%S")
name = f"job_{stamp}_{secrets.token_hex(3)}.py"
path = os.path.join(jobs_dir, name)
with open(path, "w", encoding="utf-8") as f:
f.write(code)
return os.path.abspath(path)
```
- [ ] **Step 4: Run test to verify it passes**
```bash
uv run pytest tests/unit/test_job_writer.py -v
```
Expected: 2 passed.
- [ ] **Step 5: Commit**
```bash
git add spark_executor/core/job_writer.py tests/unit/test_job_writer.py
git commit -m "feat: add job_writer for LLM-generated PySpark"
```
---
### Task 22: `write_job_file` MCP tool
**Files:**
- Create: `spark_executor/tools/write_job.py`
- Test: `tests/unit/test_write_job_tool.py`
- Modify: `spark_executor/server.py` (register the new route)
- Modify: `tests/integration/test_mcp_routes.py` (assert new route registered)
**Interfaces:**
- Produces:
- `def write_job_file(code: str) -> dict[str, str]` — returns `{"script_path": "..."}`. The `code` parameter is the full PySpark source to write to disk.
- [ ] **Step 1: Write the failing test**
`tests/unit/test_write_job_tool.py`:
```python
# coding=utf-8
import os
import tempfile
from pathlib import Path
from spark_executor.tools import generate
def test_write_job_file_writes_and_returns_path(tmp_path: Path, monkeypatch):
monkeypatch.setattr(generate, "DEFAULT_JOBS_DIR", str(tmp_path))
out = generate.write_job_file("spark = SparkSession.builder.getOrCreate()\n")
assert os.path.isfile(out["script_path"])
with open(out["script_path"]) as f:
assert f.read() == "spark = SparkSession.builder.getOrCreate()\n"
```
- [ ] **Step 2: Run test to verify it fails**
```bash
uv run pytest tests/unit/test_write_job_tool.py -v
```
Expected: `ModuleNotFoundError: No module named 'spark_executor.tools.generate'`.
- [ ] **Step 3: Implement `generate.py`**
```python
# coding=utf-8
"""
@Time :2026/6/24
@Author :tao.chen
"""
from common.logging import logger
from spark_executor.core.job_writer import write_job_file
DEFAULT_JOBS_DIR = "/tmp/jobs"
def write_job_file(code: str) -> dict[str, str]:
path = write_job_file(code, jobs_dir=DEFAULT_JOBS_DIR)
logger.info(f"write_job_file script_path={path}")
return {"script_path": path}
```
- [ ] **Step 4: Run test to verify it passes**
```bash
uv run pytest tests/unit/test_write_job_tool.py -v
```
Expected: 1 passed.
- [ ] **Step 5: Register the route in `server.py`**
In `spark_executor/server.py`, add import and route:
```python
from spark_executor.tools.write_job import write_job_file
```
```python
@app.post("/write_job_file")
def _write_job_file(code: str):
return write_job_file(code)
```
- [ ] **Step 6: Update integration test to assert the new route**
In `tests/integration/test_mcp_routes.py`, add to the loop in `test_eight_tool_routes_registered`:
```python
"/write_job_file",
```
- [ ] **Step 7: Run full suite**
```bash
uv run pytest -v
```
Expected: all tests pass (77 total).
- [ ] **Step 8: Commit**
```bash
git add spark_executor/tools/write_job.py spark_executor/server.py tests/unit/test_write_job_tool.py tests/integration/test_mcp_routes.py
git commit -m "feat: add write_job_file MCP tool"
```
---
### Stage 2 checkpoint
You can now demonstrate the full agent loop:
```
save_connection(name=..., master=..., ...) -> {"name":..., "status":"SAVED"}
list_connections() -> [Connection, ...]
write_job_file(code=...) -> script_path
prepare_submit_job(connection=..., script_path=...) -> pending_id (no spark-submit)
confirm_submit_job(pending_id=...) -> job_id, application_id
get_job_status(job_id=...) -> RUNNING | SUCCEEDED | FAILED | KILLED
get_job_logs(job_id=...) -> tail of driver logs
kill_job(job_id=...) -> KILLED
```
The submission step is now a two-phase commit: `prepare_submit_job` snapshots the connection's `master` / `deploy_mode` / `spark_conf` into a `PendingSubmission` record, and only `confirm_submit_job` actually invokes `spark-submit`. Edits to the named `Connection` between prepare and confirm are intentionally ignored.
---
# Stage 3 — Async, registry, log cache, multi-tenant (★★★☆☆)
> **Note:** Stage 3 is a non-trivial refactor. It is broken out here at a planning level only — **do not implement it as part of this plan**. A separate plan should be authored for Stage 3 once Stage 2 is shipping. This section exists so the Stage-1 / Stage-2 code can be structured to make Stage 3 cheap.
### Why Stage 3 needs its own plan
Stage 3 changes the runtime model:
1. `submit_job` becomes non-blocking: it returns `job_id` immediately, and `spark-submit` runs as an `asyncio.create_subprocess_exec` task in the background.
2. The in-memory `JobStore` (Task 5) is replaced with a SQLite-backed `JobRegistry` (`spark_executor/core/job_registry.py`). Schema: `job_id TEXT PK, application_id TEXT, status TEXT, submit_time TEXT, owner TEXT, connection TEXT, script_path TEXT, queue TEXT, params JSON`. The `connection` table mirrors `ConnectionStore` records.
3. A status poller (`spark_executor/core/status_poller.py`) runs as a FastAPI `lifespan` background task, periodically refreshing `status` for every non-terminal job via `get_application_status`.
4. `get_job_logs` no longer calls `yarn logs` on every request. A `LogCache` (`spark_executor/core/log_cache.py`) persists `yarn logs` output to a per-application file under `.log_cache/.log` and accepts an `offset` parameter; subsequent calls only fetch logs after that offset.
5. An `owner` field is added to `Job` and threaded through every tool, with a filter so a tenant can only see their own jobs. Connection records may also be scoped per-owner at this point.
### Implications for Stage 1 / Stage 2 code (so Stage 3 stays cheap)
These are constraints the current plan already respects:
- **Tool functions take keyword-only arguments** and return a Pydantic model. Stage 3 will add an `owner` kwarg without changing call sites.
- **`JobStore` is a class with `put / get / list`**, not module-level globals. Stage 3 swaps the implementation; the surface stays the same.
- **`ConnectionStore` is a class with `list_all / get / save / delete`** keyed by `name`, with a JSON file under `SPARK_EXECUTOR_DATA_DIR` (default `./data/`). Stage 3 may migrate it into the same SQLite DB; the surface is unchanged.
- **`PendingStore` is a class with `list_all / get / save / delete`** keyed by `pending_id`, with a JSON file in the same `data_dir`. The two-step `prepare` → `confirm` flow is intentionally synchronous and CPU-only; Stage 3 does not need to change it to introduce async submission, because `confirm_submit_job` already separates "what to submit" (the persisted snapshot) from "where to submit" (the YARN call). The async refactor in Stage 3 will move the *YARN call* inside `confirm_submit_job` to a background task; the prepare/confirm shape and the `PendingStore` interface stay the same.
- **All `yarn`/`spark-submit` calls go through `core/` modules** that return data, never raise HTTP errors directly. Stage 3 wraps them in async equivalents.
- **Pydantic `Job` includes `submit_time`, `script_path`, `queue`, and `connection` already** — Stage 3 only needs to add `owner` and `status`. SQLite migration is one `CREATE TABLE` away.
- **`PendingSubmission` carries a snapshot of `master` / `deploy_mode` / `spark_conf`** at prepare time, not a reference to a live `Connection`. This means Stage 3's multi-tenant scoping can be enforced at *prepare* time (rejecting unauthorised connections) without needing a connection lookup at confirm time.
### Stage 3 task outline (deferred)
When the user is ready, a new plan should be written with at minimum:
1. Async `confirm_submit_job`: `asyncio.create_subprocess_exec` wrapper, persist `Job` with `status="SUBMITTING"` immediately and update to `SUBMITTED`/`FAILED` when the background task completes. `prepare_submit_job` is unchanged.
2. SQLite `JobRegistry` with `init_db()` called from `main.py` lifespan.
3. Status poller task started in lifespan, cancelled on shutdown.
4. `LogCache` writing per-application logs to disk; `get_job_logs(job_id, offset=0)`.
5. `owner` parameter plumbed through all twelve tools (5 pending + 3 job + 4 connection); `JobStore.list(owner=...)` and `PendingStore.list_all(owner=...)` filters.
6. `get_job_status` returns cached `status` from the registry (refreshed by poller), not a live YARN call.
Target size: 1000–2000 lines of Python, including tests.
---
## Self-Review Notes (author checklist)
- **Spec coverage:**
- Stage 1 covers the job lifecycle tools (`get_job_status` / `get_job_logs` / `kill_job`), `application_id` extraction (both forms), 5000-char log tail, and YARN CLI invocation. ✔
- Stage 1 also covers the connection-management requirement: 4 tools (save/list/get/delete) backed by `ConnectionStore` (JSON at `./data/connections.json`, overridable via `SPARK_EXECUTOR_DATA_DIR`). ✔
- Stage 1 also covers the two-step pending submission requirement (`prepare_submit_job` / `confirm_submit_job` / `list_pending_jobs` / `get_pending_job` / `cancel_pending_job`): parameters are snapshotted into a `PendingSubmission` and persisted to `./data/pending_jobs.json`; `spark-submit` is only invoked by `confirm_submit_job`, which is the user-second-confirmation step. ✔
- Stage 2 covers `write_job_file` writing to `/tmp/jobs/job_xxx.py`. ✔
- Stage 3 is documented as deferred; the Stage-1/Stage-2 code is shaped to make it cheap. ✔
- **Placeholder scan:** No "TBD" or "implement later" markers. All steps contain code or concrete commands. Stage 3 is explicitly out of scope and named as such.
- **Type consistency:** `Job`, `JobStatus`, `SubmitResult`, `Connection`, `PendingSubmission` Pydantic models defined in Task 1. `JobStore.put/get/list` is consistent across the three job-lifecycle tools (Tasks 17–19). `ConnectionStore.list_all/get/save/delete` is consistent across `connections.py` (Tasks 7–10) and `submit.py` reads it in Task 12. `PendingStore.list_all/get/save/delete` is consistent across all five pending tools (Tasks 12–16); `confirm_submit_job` writes back through it with status transitions. `Job.connection` is set by `confirm_submit_job` (Task 13) and consumed by `get_job_status` / `get_job_logs` / `kill_job` (Tasks 17–19).