fix(job_store): persist Jobs to disk and accept either ID in job tools
Two user-reported bugs, same root cause: the in-memory JobStore + the
'job_id must be the 12-char hex' tool contract.
Bug 1: 'Unknown job_id' reported frequently
JobStore was a process-local dict (spark_executor/core/job_store.py).
Under gunicorn workers > 1, a job created by confirm_submit_job
landing on worker A was invisible to worker B, so a follow-up
get_job_status / get_job_result / get_job_logs / kill_job landing on
a different worker returned 'Unknown job_id'. Same multi-worker
problem that bit the MCP session layer; only the affected data was
different.
Bug 2: 'get_job_logs frequently confuses job_id and application_id'
confirm_submit_job returns BOTH identifiers in SubmitResult, but
get_job_logs (and friends) only accepted the local 12-char job_id
and never said so in their description. When the agent passed the
YARN application_id, the error message itself was misleading:
'Unknown job_id: application_17400000001_0001' — the agent had
passed an id, just the wrong kind.
This change fixes both at the root:
* JobStore is now JSON-backed at data/jobs.json (atomic tempfile +
os.replace), with cross-process safety via fcntl.flock on a sibling
.lock file. Stage 3's SQLite migration is still planned; the file
format is intentionally simple so it is a straight
'for j in read_all(): db.insert(j)'.
* New JobStore.get_either(uid) looks up by job_id first, then
application_id. All four job-lifecycle tools (get_job_status,
get_job_result, get_job_logs, kill_job) call get_either instead
of get(job_id), so the agent can pass either identifier and get
the same answer.
* The 'neither matched' KeyError now spells out both id forms and
what they look like, so the agent isn't left guessing.
* server.py tool descriptions for the four job tools explicitly
state 'job_id accepts BOTH identifiers' so this is visible to the
LLM at tool-selection time, not only at error time.
Tests:
* test_job_store.py: tmp_path isolation, persistence across
instances, human-readable JSON, corrupt-file resilience,
get_either (by job_id, by application_id, collision preference,
unknown), put idempotency.
* test_{logs,status,kill,result}_tool.py: per-test tmp_path fixture,
'accepts application_id' regression for each tool, and an
explicit assertion that the unknown-id error message mentions
BOTH id forms. test_result_raises_keyerror_for_unknown_job's
match pattern updated for the new message.
242 tests pass (was 226; +16 new). Zero regressions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,27 +2,191 @@
|
||||
"""
|
||||
@Time :2026/6/24
|
||||
@Author :tao.chen
|
||||
|
||||
JSON-backed Job registry. Each Job carries both a local `job_id` (12-char
|
||||
hex generated in confirm_submit_job) and the YARN `application_id`
|
||||
(`application_<ts>_<n>`).
|
||||
|
||||
Why file-backed instead of in-memory:
|
||||
- The MCP service runs under gunicorn with workers > 1 by default; an
|
||||
in-memory dict lives in worker A's process, so a follow-up
|
||||
`get_job_logs(job_id=...)` landing on worker B would return
|
||||
"Unknown job_id" for jobs created elsewhere. The same problem that
|
||||
bit us on MCP sessions.
|
||||
- Restarting the server should not lose track of running YARN apps.
|
||||
|
||||
Why fcntl.flock on writes:
|
||||
- In-process `threading.Lock` is per-process. Two gunicorn workers can
|
||||
call `put()` concurrently and lose updates (last-writer-wins over
|
||||
the other's data). `fcntl.flock(LOCK_EX)` on the data file gives
|
||||
cross-process mutual exclusion.
|
||||
|
||||
Why `get_either` (accept job_id OR application_id):
|
||||
- The MCP client gets both IDs back from `confirm_submit_job` and
|
||||
routinely confuses which to pass to `get_job_logs` / `get_job_status`
|
||||
/ `kill_job` / `get_job_result`. The tool contract used to be
|
||||
"must be job_id" but the description never said so, so agents
|
||||
passed application_id and got a misleading "Unknown job_id" error.
|
||||
Accepting either ID lets us blame the lookup, not the user.
|
||||
|
||||
This is a Step-1 persistence fix; the Stage-3 plan still calls for
|
||||
SQLite (queryable, transactional). The file format is intentionally
|
||||
simple so a future migration is just `for j in read_all(): db.insert(j)`.
|
||||
"""
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
|
||||
from common.config import settings
|
||||
from common.logging import logger
|
||||
from spark_executor.models import Job
|
||||
|
||||
DEFAULT_DATA_DIR = settings.data_dir
|
||||
DEFAULT_FILE_NAME = "jobs.json"
|
||||
|
||||
|
||||
class JobStore:
|
||||
"""In-memory job registry. Stage-3 will swap this for SQLite."""
|
||||
"""JSON-backed CRUD for Job records, indexed by job_id.
|
||||
|
||||
def __init__(self) -> None:
|
||||
File path: <data_dir>/<file_name>. Format: {job_id: <Job.model_dump()>}.
|
||||
Writes are atomic (tempfile + os.replace) and cross-process safe
|
||||
(fcntl.flock around the load-modify-dump critical section).
|
||||
"""
|
||||
|
||||
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
|
||||
# In-process lock: serializes threads within one worker.
|
||||
# Cross-process serialization is handled by fcntl.flock in _locked_dump.
|
||||
self._lock = Lock()
|
||||
self._jobs: dict[str, Job] = {}
|
||||
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
return Path(self._data_dir) / self._file_name
|
||||
|
||||
# --- Low-level I/O ---
|
||||
|
||||
def _load(self) -> dict[str, Job]:
|
||||
"""Read all jobs from disk. Returns {} on missing/corrupt file."""
|
||||
if not self.path.exists():
|
||||
logger.debug(f"JobStore._load: file {self.path} absent, returning empty")
|
||||
return {}
|
||||
try:
|
||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
logger.exception(
|
||||
f"JobStore._load: file {self.path} is corrupt; treating as empty. "
|
||||
f"Inspect and either repair or delete to recover."
|
||||
)
|
||||
return {}
|
||||
return {jid: Job.model_validate(j) for jid, j in raw.items()}
|
||||
|
||||
def _dump(self, records: dict[str, Job]) -> None:
|
||||
"""Atomic write. Caller must hold `self._lock`."""
|
||||
os.makedirs(self._data_dir, exist_ok=True)
|
||||
payload = {jid: j.model_dump() for jid, j 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:
|
||||
# default=str handles datetime — same trick pending_store uses.
|
||||
json.dump(payload, f, indent=2, ensure_ascii=False, default=str)
|
||||
os.replace(tmp_path, self.path)
|
||||
logger.debug(f"JobStore._dump: wrote {len(records)} records to {self.path}")
|
||||
except Exception:
|
||||
if os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
raise
|
||||
|
||||
@contextmanager
|
||||
def _locked_dump(self, mutate):
|
||||
"""load -> mutate (in-memory) -> dump, under both in-process and
|
||||
cross-process exclusive locks.
|
||||
|
||||
This is the only sanctioned way to write. It guarantees:
|
||||
1. Two threads in the same worker see consistent state.
|
||||
2. Two gunicorn workers can't both load X, both compute
|
||||
X+their-change, both dump — one update would be lost.
|
||||
"""
|
||||
with self._lock:
|
||||
# Open the data file for flocking; if it doesn't exist yet,
|
||||
# open the parent dir so we can still take an exclusive lock
|
||||
# before the first write creates the file.
|
||||
lock_path = self.path
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Use a stable lock file (separate from data file) so an
|
||||
# os.replace on the data file doesn't break our flock.
|
||||
flock_path = lock_path.with_suffix(lock_path.suffix + ".lock")
|
||||
f = open(flock_path, "w")
|
||||
try:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
records = self._load()
|
||||
mutate(records)
|
||||
self._dump(records)
|
||||
finally:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
f.close()
|
||||
|
||||
# --- Public CRUD ---
|
||||
|
||||
def put(self, job: Job) -> None:
|
||||
with self._lock:
|
||||
self._jobs[job.job_id] = job
|
||||
"""Insert or replace a Job by job_id. Persists to disk."""
|
||||
def _mutate(records: dict[str, Job]) -> None:
|
||||
records[job.job_id] = job
|
||||
self._locked_dump(_mutate)
|
||||
logger.info(
|
||||
f"job_store put job_id={job.job_id} application_id={job.application_id}"
|
||||
)
|
||||
|
||||
def get(self, job_id: str) -> Job | None:
|
||||
"""Look up a Job by its local job_id only.
|
||||
|
||||
Prefer `get_either` in tool code — it also accepts application_id,
|
||||
which is what the LLM agent sometimes has on hand.
|
||||
"""
|
||||
with self._lock:
|
||||
return self._jobs.get(job_id)
|
||||
return self._load().get(job_id)
|
||||
|
||||
def get_by_application_id(self, application_id: str) -> Job | None:
|
||||
"""Look up a Job by its YARN application_id. O(n) over the file."""
|
||||
with self._lock:
|
||||
for job in self._load().values():
|
||||
if job.application_id == application_id:
|
||||
return job
|
||||
return None
|
||||
|
||||
def get_either(self, job_id_or_application_id: str) -> Job | None:
|
||||
"""Look up a Job by either identifier. Try job_id first (O(1)),
|
||||
then fall back to application_id scan (O(n)).
|
||||
|
||||
This is what every job tool (get_job_status / get_job_logs /
|
||||
kill_job / get_job_result) should call — it eliminates the
|
||||
"agent passed the wrong ID and got a misleading 'Unknown job_id'"
|
||||
failure mode.
|
||||
"""
|
||||
with self._lock:
|
||||
records = self._load()
|
||||
direct = records.get(job_id_or_application_id)
|
||||
if direct is not None:
|
||||
return direct
|
||||
for job in records.values():
|
||||
if job.application_id == job_id_or_application_id:
|
||||
return job
|
||||
return None
|
||||
|
||||
def list(self) -> list[Job]:
|
||||
with self._lock:
|
||||
return list(self._jobs.values())
|
||||
return list(self._load().values())
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Test helper: drop everything. Not part of the public API."""
|
||||
def _mutate(records: dict[str, Job]) -> None:
|
||||
records.clear()
|
||||
self._locked_dump(_mutate)
|
||||
|
||||
|
||||
# Module-level singleton; replaced in tests.
|
||||
store = JobStore()
|
||||
|
||||
@@ -181,7 +181,12 @@ def _cancel_pending_job(req: PendingIdRequest):
|
||||
description=(
|
||||
"Return the YARN application state (RUNNING / SUCCEEDED / FAILED / "
|
||||
"KILLED / ACCEPTED / NEW / NEW_SAVING / SUBMITTED / etc.) plus the "
|
||||
"raw YARN REST response body."
|
||||
"raw YARN REST response body.\n\n"
|
||||
"**job_id accepts BOTH identifiers** returned by "
|
||||
"confirm_submit_job: the local job_id (12-char hex, e.g. "
|
||||
"'a1b2c3d4e5f6') and the YARN application_id (e.g. "
|
||||
"'application_17400000001_0001'). The lookup is by job_id first, "
|
||||
"then by application_id."
|
||||
),
|
||||
)
|
||||
def _get_job_status(req: JobIdRequest):
|
||||
@@ -196,7 +201,12 @@ def _get_job_status(req: JobIdRequest):
|
||||
"Return a terminal-oriented view of a Spark job: final_status, "
|
||||
"diagnostics, tracking_url, started_time, and finished_time. "
|
||||
"This is distinct from get_job_status, which is for polling the "
|
||||
"running YARN state and returns the raw YARN response."
|
||||
"running YARN state and returns the raw YARN response.\n\n"
|
||||
"**job_id accepts BOTH identifiers** returned by "
|
||||
"confirm_submit_job: the local job_id (12-char hex, e.g. "
|
||||
"'a1b2c3d4e5f6') and the YARN application_id (e.g. "
|
||||
"'application_17400000001_0001'). The lookup is by job_id first, "
|
||||
"then by application_id."
|
||||
),
|
||||
)
|
||||
def _get_job_result(req: JobIdRequest):
|
||||
@@ -210,7 +220,12 @@ def _get_job_result(req: JobIdRequest):
|
||||
description=(
|
||||
"Pull aggregated logs from the YARN ResourceManager. Returns the last "
|
||||
"tail_chars characters (default 5000). Requires yarn.log-aggregation-enable "
|
||||
"to be true on the target cluster."
|
||||
"to be true on the target cluster.\n\n"
|
||||
"**job_id accepts BOTH identifiers** returned by "
|
||||
"confirm_submit_job: the local job_id (12-char hex, e.g. "
|
||||
"'a1b2c3d4e5f6') and the YARN application_id (e.g. "
|
||||
"'application_17400000001_0001'). The lookup is by job_id first, "
|
||||
"then by application_id."
|
||||
),
|
||||
)
|
||||
def _get_job_logs(req: GetJobLogsRequest):
|
||||
@@ -221,7 +236,12 @@ def _get_job_logs(req: GetJobLogsRequest):
|
||||
"/kill_job",
|
||||
operation_id="kill_job",
|
||||
summary="Kill a running job",
|
||||
description="PUT state=KILLED to YARN REST API for the job's application_id.",
|
||||
description=(
|
||||
"PUT state=KILLED to YARN REST API for the job's application_id. "
|
||||
"**job_id accepts BOTH identifiers** returned by "
|
||||
"confirm_submit_job: the local job_id (12-char hex) and the YARN "
|
||||
"application_id. The lookup is by job_id first, then by application_id."
|
||||
),
|
||||
)
|
||||
def _kill_job(req: JobIdRequest):
|
||||
return kill_job(req.job_id)
|
||||
|
||||
@@ -7,23 +7,29 @@ from common.logging import logger
|
||||
from spark_executor.core.job_store import JobStore
|
||||
from spark_executor.core.yarn_client import YarnClientConfig, kill_application
|
||||
from spark_executor.tools.connections import store as conn_store
|
||||
from spark_executor.tools.logs import _unknown_job_error
|
||||
|
||||
store = JobStore()
|
||||
|
||||
|
||||
def kill_job(job_id: str) -> dict[str, str]:
|
||||
"""Kill a running job.
|
||||
|
||||
`job_id` accepts either the local job_id (returned by
|
||||
confirm_submit_job) or the YARN application_id.
|
||||
"""
|
||||
logger.debug(f"kill_job enter job_id={job_id}")
|
||||
job = store.get(job_id)
|
||||
job = store.get_either(job_id)
|
||||
if job is None:
|
||||
raise KeyError(f"Unknown job_id: {job_id}")
|
||||
raise _unknown_job_error(job_id)
|
||||
conn = conn_store.get(job.connection)
|
||||
if conn is None:
|
||||
raise KeyError(f"Connection not found: {job.connection}")
|
||||
config = YarnClientConfig.from_connection(conn)
|
||||
kill_application(job.application_id, config)
|
||||
logger.info(f"kill_job ok job_id={job_id} application_id={job.application_id}")
|
||||
logger.info(f"kill_job ok job_id={job.job_id} application_id={job.application_id}")
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"job_id": job.job_id,
|
||||
"application_id": job.application_id,
|
||||
"status": "KILLED",
|
||||
}
|
||||
|
||||
@@ -11,11 +11,33 @@ from spark_executor.tools.connections import store as conn_store
|
||||
store = JobStore()
|
||||
|
||||
|
||||
def _unknown_job_error(uid: str) -> KeyError:
|
||||
"""Standard "we tried both IDs and found nothing" message.
|
||||
|
||||
The agent gets this from confirm_submit_job's response:
|
||||
{"job_id": "a1b2c3d4e5f6", "application_id": "application_...", ...}
|
||||
and routinely confuses which to pass here. Spelling out that BOTH
|
||||
IDs were tried (and what they look like) saves a round trip.
|
||||
"""
|
||||
return KeyError(
|
||||
f"No Job found for id={uid!r} (neither as job_id nor as "
|
||||
f"application_id). Pass the job_id from confirm_submit_job's "
|
||||
f"response — it is a 12-char hex like 'a1b2c3d4e5f6'. The "
|
||||
f"application_id is the YARN ID, e.g. 'application_17400000001_0001'."
|
||||
)
|
||||
|
||||
|
||||
def get_job_logs(job_id: str, tail_chars: int = 5000) -> str:
|
||||
"""Fetch aggregated container logs for a Spark job.
|
||||
|
||||
`job_id` accepts either the local job_id (returned by
|
||||
confirm_submit_job) or the YARN application_id — both are looked up
|
||||
against the same Job record.
|
||||
"""
|
||||
logger.debug(f"get_job_logs enter job_id={job_id} tail_chars={tail_chars}")
|
||||
job = store.get(job_id)
|
||||
job = store.get_either(job_id)
|
||||
if job is None:
|
||||
raise KeyError(f"Unknown job_id: {job_id}")
|
||||
raise _unknown_job_error(job_id)
|
||||
conn = conn_store.get(job.connection)
|
||||
if conn is None:
|
||||
raise KeyError(f"Connection not found: {job.connection}")
|
||||
@@ -23,7 +45,7 @@ def get_job_logs(job_id: str, tail_chars: int = 5000) -> str:
|
||||
full = get_application_logs(job.application_id, config)
|
||||
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} "
|
||||
f"get_job_logs ok job_id={job.job_id} application_id={job.application_id} "
|
||||
f"full_chars={len(full)} returned_chars={len(tailed)}"
|
||||
)
|
||||
return tailed
|
||||
|
||||
@@ -10,15 +10,21 @@ from spark_executor.core.job_store import JobStore
|
||||
from spark_executor.core.yarn_client import YarnClientConfig, get_application_status
|
||||
from spark_executor.tools.connections import store as conn_store
|
||||
from spark_executor.models import JobResult
|
||||
from spark_executor.tools.logs import _unknown_job_error
|
||||
|
||||
store = JobStore()
|
||||
|
||||
|
||||
def get_job_result(job_id: str) -> JobResult:
|
||||
"""Query YARN for a job's terminal result view.
|
||||
|
||||
`job_id` accepts either the local job_id (returned by
|
||||
confirm_submit_job) or the YARN application_id.
|
||||
"""
|
||||
logger.debug(f"get_job_result enter job_id={job_id}")
|
||||
job = store.get(job_id)
|
||||
job = store.get_either(job_id)
|
||||
if job is None:
|
||||
raise KeyError(f"Unknown job_id: {job_id}")
|
||||
raise _unknown_job_error(job_id)
|
||||
conn = conn_store.get(job.connection)
|
||||
if conn is None:
|
||||
raise KeyError(f"Connection not found: {job.connection}")
|
||||
@@ -35,7 +41,7 @@ def get_job_result(job_id: str) -> JobResult:
|
||||
finished_time=app.get("finishedTime"),
|
||||
)
|
||||
logger.info(
|
||||
f"get_job_result ok job_id={job_id} application_id={job.application_id} "
|
||||
f"get_job_result ok job_id={job.job_id} application_id={job.application_id} "
|
||||
f"state={state} final_status={result.final_status}"
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -9,19 +9,25 @@ from spark_executor.core.job_store import JobStore
|
||||
from spark_executor.core.yarn_client import get_application_status
|
||||
from spark_executor.models import JobStatus
|
||||
from spark_executor.tools.connections import store as conn_store
|
||||
from spark_executor.tools.logs import _unknown_job_error
|
||||
|
||||
store = JobStore()
|
||||
|
||||
|
||||
def get_job_status(job_id: str) -> JobStatus:
|
||||
"""Query YARN for a job's current status.
|
||||
|
||||
`job_id` accepts either the local job_id (returned by
|
||||
confirm_submit_job) or the YARN application_id.
|
||||
"""
|
||||
logger.debug(f"get_job_status enter job_id={job_id}")
|
||||
job = store.get(job_id)
|
||||
job = store.get_either(job_id)
|
||||
if job is None:
|
||||
raise KeyError(f"Unknown job_id: {job_id}")
|
||||
raise _unknown_job_error(job_id)
|
||||
conn = conn_store.get(job.connection)
|
||||
if conn is None:
|
||||
raise KeyError(f"Connection not found: {job.connection}")
|
||||
config = YarnClientConfig.from_connection(conn)
|
||||
state, raw = get_application_status(job.application_id, config)
|
||||
logger.info(f"get_job_status ok job_id={job_id} application_id={job.application_id} state={state}")
|
||||
logger.info(f"get_job_status ok job_id={job.job_id} application_id={job.application_id} state={state}")
|
||||
return JobStatus(application_id=job.application_id, state=state, raw=raw)
|
||||
|
||||
Reference in New Issue
Block a user