feat(pending): app_name field and date-sharded pending storage

- Add optional app_name to PendingSubmission and prepare_submit_job,
  passed through PrepareSubmitJobRequest for user-provided tracking.
- Rewrite PendingStore to shard records by UTC date under
  data/pending_jobs/YYYY-MM-DD.json instead of a single monolithic
  pending_jobs.json.
- Legacy single-file pending_jobs.json remains readable; first save()
  migrates it and renames the old file to avoid double reads.
- Tests cover date sharding, multi-date list, legacy read, legacy
  migration, and app_name round-trip.
This commit is contained in:
Claude
2026-06-26 14:36:24 +08:00
parent c44e9bcc55
commit da9ba657f3
7 changed files with 163 additions and 35 deletions
+1
View File
@@ -105,6 +105,7 @@ def test_pending_submission_defaults_to_pending_status():
assert p.job_id is None
assert p.application_id is None
assert p.yarn_rm_url is None # default for connections without one set
assert p.app_name is None
def test_pending_submission_carries_yarn_rm_url_snapshot():
+51 -4
View File
@@ -8,7 +8,7 @@ from spark_executor.core import pending_store
from spark_executor.models import PendingSubmission
def _pending(pid: str = "p_1") -> PendingSubmission:
def _pending(pid: str = "p_1", created_at: datetime | None = None) -> PendingSubmission:
return PendingSubmission(
pending_id=pid,
connection="prod",
@@ -20,7 +20,7 @@ def _pending(pid: str = "p_1") -> PendingSubmission:
executor_cores=2,
num_executors=2,
spark_conf={},
created_at=datetime(2026, 6, 24),
created_at=created_at or datetime(2026, 6, 24),
)
@@ -38,9 +38,9 @@ def test_save_then_get_roundtrip():
assert out.master == "yarn"
def test_save_persists_to_json(tmp_path: Path):
def test_save_persists_to_date_file(tmp_path: Path):
pending_store.store.save(_pending())
path = tmp_path / "pending_jobs.json"
path = tmp_path / "pending_jobs" / "2026-06-24.json"
assert path.is_file()
@@ -58,6 +58,53 @@ def test_list_all_returns_saved():
assert {p.pending_id for p in pending_store.store.list_all()} == {"a", "b"}
def test_list_all_spans_multiple_date_files():
pending_store.store.save(_pending("a", created_at=datetime(2026, 6, 24)))
pending_store.store.save(_pending("b", created_at=datetime(2026, 6, 25)))
assert {p.pending_id for p in pending_store.store.list_all()} == {"a", "b"}
def test_get_searches_date_files():
pending_store.store.save(_pending("a", created_at=datetime(2026, 6, 24)))
assert pending_store.store.get("a") is not None
def test_delete_removes_from_date_file_and_deletes_empty_file():
pending_store.store.save(_pending("a", created_at=datetime(2026, 6, 24)))
date_file = pending_store.store.dir_path / "2026-06-24.json"
assert date_file.is_file()
assert pending_store.store.delete("a") is True
assert pending_store.store.get("a") is None
assert not date_file.exists()
def _legacy_record_text() -> str:
return (
'{"p_legacy": {"pending_id": "p_legacy", "connection": "prod", '
'"master": "yarn", "deploy_mode": "cluster", "script_path": "/tmp/j.py", '
'"queue": "default", "executor_memory": "4G", "executor_cores": 2, '
'"num_executors": 2, "spark_conf": {}, '
'"created_at": "2026-06-23T00:00:00"}}'
)
def test_legacy_file_is_readable(tmp_path: Path):
legacy = tmp_path / "pending_jobs.json"
legacy.write_text(_legacy_record_text(), encoding="utf-8")
assert pending_store.store.get("p_legacy") is not None
assert any(p.pending_id == "p_legacy" for p in pending_store.store.list_all())
def test_save_migrates_legacy_file(tmp_path: Path):
legacy = tmp_path / "pending_jobs.json"
legacy.write_text(_legacy_record_text(), encoding="utf-8")
pending_store.store.save(_pending("p_new", created_at=datetime(2026, 6, 24)))
assert not legacy.exists()
assert pending_store.store.get("p_legacy") is not None
migrated = pending_store.store.dir_path / "2026-06-23.json"
assert migrated.is_file()
def test_save_upserts_existing():
pending_store.store.save(_pending("a"))
updated = _pending("a")
+18
View File
@@ -74,6 +74,24 @@ def test_prepare_persists_pending_with_snapshot(monkeypatch, real_script):
assert p.script_path == str(real_script)
def test_prepare_persists_app_name(monkeypatch, real_script):
monkeypatch.setattr(submit, "run_spark_submit", lambda cmd: None)
submit.prepare_submit_job(
connection="prod", script_path=str(real_script), app_name="my-etl-job"
)
p = submit.pending_store.get(_last_pending_id())
assert p is not None
assert p.app_name == "my-etl-job"
def test_prepare_app_name_defaults_to_none(monkeypatch, real_script):
monkeypatch.setattr(submit, "run_spark_submit", lambda cmd: None)
submit.prepare_submit_job(connection="prod", script_path=str(real_script))
p = submit.pending_store.get(_last_pending_id())
assert p is not None
assert p.app_name is None
def test_prepare_raises_for_unknown_connection(real_script):
with pytest.raises(KeyError, match="missing"):
submit.prepare_submit_job(connection="missing", script_path=str(real_script))