Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions amorphouspy_api/src/amorphouspy_api/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,16 @@

Typical workflow:
1. `submit_job` — submit a simulation (returns job ID + URLs).
2. `get_job_status` — poll until status is "completed".
3. `get_job_results` — retrieve analysis data.
4. `get_job_settings` — inspect the original submission parameters.
5. `search_jobs` — find existing results for similar compositions.
6. `list_glasses` / `lookup_glass` — browse available compositions.
2. `search_jobs` — find existing jobs by exact filters (composition, tags, status, time window).
3. `get_job_status` — confirm live per-job status before fetching results.
4. `get_job_results` — retrieve analysis data.
5. `get_job_settings` — inspect the original submission parameters.
6. `list_glasses` / `lookup_glass` — browse completed compositions/results.

Notes:
- `search_jobs` is a listing/filter tool, not a similarity search.
- For nearest/close composition matching among completed results, use `lookup_glass`.
- When status accuracy matters for jobs returned by `search_jobs`, call `get_job_status` for each job ID.
"""

mcp = FastMCP(
Expand Down
15 changes: 13 additions & 2 deletions amorphouspy_api/src/amorphouspy_api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -661,11 +661,22 @@ class JobSearchRequest(BaseModel):
analyses: list[str] | None = None
tags: list[str] | None = Field(
default=None,
description="Filter to jobs with all specified tags",
description="Filter to jobs that contain all specified tags (logical AND).",
)
statuses: list[JobStatus] | None = Field(
default=None,
description=("Filter to jobs with these statuses. If not provided, all statuses are included."),
description=(
"Filter to jobs with these statuses. If not provided, all statuses are included. "
"Status values are read from stored job records (snapshot semantics). "
"For live per-job refresh, call GET /jobs/{job_id}."
),
)
refresh_status: bool = Field(
default=True,
description=(
"If true (default), refresh jobs currently marked running against executor cache "
"before returning results. Set to false for snapshot-only DB reads."
),
)
created_after: datetime | None = Field(
default=None,
Expand Down
25 changes: 25 additions & 0 deletions amorphouspy_api/src/amorphouspy_api/routers/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,13 @@ def search_jobs(body: JobSearchRequest) -> JobSearchResponse:
Use *created_after* / *created_before* to constrain results to a
creation-time window (inclusive ISO 8601 datetimes).

By default, jobs currently marked ``running`` are refreshed against
executor cache state before matches are returned. Set
``refresh_status=false`` to return a pure DB snapshot.

Tag filtering uses "all tags required" semantics: every tag supplied
in the request must be present on a job for it to be included.

To search completed results by composition similarity, use
``POST /glasses:search`` instead.
"""
Expand All @@ -365,6 +372,24 @@ def search_jobs(body: JobSearchRequest) -> JobSearchResponse:
created_after=body.created_after,
created_before=body.created_before,
)

if body.refresh_status:
refreshed_jobs = []
for job in jobs:

@ltalirz ltalirz Aug 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm yeah, this can get expensive if there are many jobs in the query (and many of them to be refreshed)

I think in terms of pattern we should move away entirely on doing these implicit updates on user read requests and have a background task take care of it.
Here is a brief summary of the proposal. You could start to use this as a basis for a new PR or I could work on it next week


Move ingestion out of the request path. GET /jobs/{id} becomes a pure SELECT; a single background process is the only writer of job state and the only component aware of both executorlib and the DB.

  • Mechanism: reconciliation loop scanning non-terminal jobs (durable, restart-safe), optionally plus add_done_callback() for lower latency.
  • Contract: the worker watches executorlib's cache_directory for completed *_o.h5 outputs, keyed by cache_key.
  • States: SUBMITTED → RUNNING → FINISHED → INGESTING → INGESTED (+ failure states). Separating "executor finished" from "results in DB" is what fixes the wrong-status queries.
  • Safety: claim via compare-and-swap or [SKIP LOCKED](https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE), idempotent upserts, retry limit.
  • Deployment: separate process, or FastAPI [lifespan](https://fastapi.tiangolo.com/advanced/events/) behind a pg_advisory_lock (otherwise it runs once per uvicorn worker).

Trade-off: status is stale by at most one poll interval — correct-but-delayed instead of fast-but-wrong.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So we scratch this PR, or do you want to approve/merge this in the meantime?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can merge it if you want, we just need to make sure to remove this logic again since it can potentially stall the process for quite a while

if job.status == "running":
refresh_job_from_cache(job)
latest = store.get_job(job.job_id)
if latest is not None:
refreshed_jobs.append(latest)
continue
refreshed_jobs.append(job)
jobs = refreshed_jobs

# ``statuses`` is applied in the DB query for performance. Re-apply it
# after optional refresh so matches respect the post-refresh truth.
if statuses is not None:
jobs = [j for j in jobs if j.status in statuses]

matches = [
JobSearchMatch(
job_id=j.job_id,
Expand Down
107 changes: 107 additions & 0 deletions amorphouspy_api/src/tests/test_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,113 @@ def test_search_jobs_date_range() -> None:
assert ids == {"j-date-new"}


def test_search_jobs_refreshes_running_status_by_default() -> None:
"""search_jobs should refresh running jobs unless explicitly disabled."""
_insert_running_job("j-running-refresh-default")

with patch("amorphouspy_api.routers.jobs.refresh_job_from_cache") as mock_refresh:
store = get_job_store()

def _mark_completed(job: Job) -> None:
store.update_job(
job.job_id,
status="completed",
progress={
"structure_generation": "completed",
"melt_quench": "completed",
"structure_characterization": "completed",
},
completed_at=datetime(2026, 8, 21, tzinfo=UTC),
)

mock_refresh.side_effect = _mark_completed

resp = client.post(
"/jobs:search",
json={"composition": {"SiO2": 100}},
)

assert resp.status_code == 200
match = next(m for m in resp.json()["matches"] if m["job_id"] == "j-running-refresh-default")
assert match["status"] == "completed"
mock_refresh.assert_called_once()


def test_search_jobs_keeps_original_record_when_refresh_returns_no_latest_job() -> None:
"""If the cache refresh yields no updated record, fall back to the DB snapshot."""
_insert_running_job("j-running-refresh-missing")

with patch("amorphouspy_api.routers.jobs.refresh_job_from_cache") as mock_refresh:
store = get_job_store()
original_get_job = store.get_job

def _get_job_missing(job_id: str) -> Job | None:
if job_id == "j-running-refresh-missing":
return None
return original_get_job(job_id)

with patch.object(store, "get_job", side_effect=_get_job_missing):
resp = client.post(
"/jobs:search",
json={"composition": {"SiO2": 100}},
)

assert resp.status_code == 200
match = next(m for m in resp.json()["matches"] if m["job_id"] == "j-running-refresh-missing")
assert match["status"] == "running"
mock_refresh.assert_called_once()


def test_search_jobs_can_skip_status_refresh() -> None:
"""refresh_status=false keeps snapshot semantics and does not touch cache."""
_insert_running_job("j-running-refresh-off")

with patch("amorphouspy_api.routers.jobs.refresh_job_from_cache") as mock_refresh:
resp = client.post(
"/jobs:search",
json={"composition": {"SiO2": 100}, "refresh_status": False},
)

assert resp.status_code == 200
match = next(m for m in resp.json()["matches"] if m["job_id"] == "j-running-refresh-off")
assert match["status"] == "running"
mock_refresh.assert_not_called()


def test_search_jobs_applies_status_filter_after_refresh() -> None:
"""A refreshed status change should still obey the requested statuses filter."""
_insert_running_job("j-running-refresh-status-filter")

with patch("amorphouspy_api.routers.jobs.refresh_job_from_cache") as mock_refresh:
store = get_job_store()

def _mark_completed(job: Job) -> None:
store.update_job(
job.job_id,
status="completed",
progress={
"structure_generation": "completed",
"melt_quench": "completed",
"structure_characterization": "completed",
},
completed_at=datetime(2026, 8, 21, tzinfo=UTC),
)

mock_refresh.side_effect = _mark_completed

# Query asks for running jobs; refreshed job flips to completed and
# must be filtered out.
resp = client.post(
"/jobs:search",
json={"composition": {"SiO2": 100}, "statuses": ["running"]},
)

assert resp.status_code == 200
ids = {m["job_id"] for m in resp.json()["matches"]}
assert "j-running-refresh-status-filter" not in ids
mock_refresh.assert_called_once()


def test_search_glasses_close_match() -> None:
"""A nearby composition should appear as a close match via glasses:search."""
_insert_completed_job("j-close-1", composition="Al2O3 15 - CaO 25 - SiO2 60")
Expand Down
Loading