Run /api/analyze synchronously: single-transaction job persistence - #779
Run /api/analyze synchronously: single-transaction job persistence#77901painadam wants to merge 4 commits into
Conversation
Extract a session-accepting add_insight core from persist_insight (flushes, never commits — the transaction boundary belongs to the caller) and add create_completed_job / create_failed_job to JobRepository. create_completed_job writes JobOrm + InsightOrm + InsightChartOrm + JobResourceOrm in ONE transaction: either the full cycle exists or nothing does, so a crash mid-cycle can no longer strand a job in pending/running or orphan an insight. Additive only — the existing background-task path is untouched until the next commit switches the runner over.
POST /api/analyze no longer fires a FastAPI BackgroundTask after the response.
The analytics pull now runs inside the request and the response is a terminal
job (completed or failed) with resources populated. Addresses the review
concern on the background-task lifecycle: a fire-and-forget task dies silently
on container restart, stranding jobs in pending/running and orphaning
insights.
- AnalysisJobRunner.run: synchronous, returns JobData; success persists via
create_completed_job (single transaction), failure via create_failed_job.
Nothing is written until the outcome is known.
- The analytics pull is capped by asyncio.timeout (50s, below the typical
60s gateway idle timeout); a timeout is recorded as a failed job.
- A persistence error propagates as a 500 — the transaction rolled back, so
a retry starts clean.
- GET /api/jobs/{id} is unchanged (response mapping extracted into
job_to_response, shared by both routers). Existing pollers see a terminal
status on the first poll.
Upstream analysis failures still return HTTP 200 with status=failed: the
failure is a job outcome already modelled by the schema, and the FE handles
it via the same shape as polling.
create_job / update_job_status / create_insight_resource have no callers now that jobs are persisted already terminal in a single transaction. Remove them from the JobRepository ABC, DBJobRepository and test fakes, and update the jobs/schemas descriptions that still described the polling lifecycle. PENDING/RUNNING enum values stay readable for rows created by the old flow.
|
Question on the design here rather than the implementation: now that A few things that made me question it:
Now that the original justification for |
yellowcap
left a comment
There was a problem hiding this comment.
Let's remove the JobOrm, see comment I left with the 🤖
…sight Addresses the review question on #779 about whether the Job abstraction still pulls its weight now that /api/analyze is synchronous: JobType had one value, /api/analyze was the only creator, GET /api/jobs/{id} had no callers, and every job produced exactly one resource pointing at exactly one insight. /api/analyze now mirrors the agent/chat path: on success it persists the insight via persist_insight (same single transaction) and returns the insight itself — the same shape as GET /api/insights/{id}. Failure persists nothing and surfaces as an HTTP error (502 upstream failure, 504 timeout) instead of a persisted failed job row, matching the chat path's philosophy where failed chart generation persists nothing. Removed: JobOrm/JobResourceOrm, JobRepository + DBJobRepository, the jobs router, Job schemas, and the add_insight split in insight_writer (persist_insight is again the single entry point). A new migration drops the jobs/job_resources tables and the updated_at trigger function; downgrade recreates them.
|
Agreed on all points — there's no product requirement for a durable audit trail of failed analyze calls, so the
One deliberate loose end: if the queue-backed future ever materialises, a job/polling contract can be reintroduced then, designed against a real second consumer. PR description updated to match. |
What & why
Addresses the review concern on #762 about the
BackgroundTasks.add_task()lifecycle, and — following the follow-up design question in this PR's review — removes theJob/JobResourceabstraction entirely rather than keeping it as a synchronous shell.The reviewer's observation held up:
JobTypehad exactly one value,/api/analyzewas the only job creator,GET /api/jobs/{id}had no frontend caller, and every job produced exactly one resource pointing at exactly one insight. With the analysis now synchronous, the layer was pure indirection.POST /api/analyzenow mirrors the agent/chat path:Either the insight exists or nothing does. A failed analysis persists nothing and surfaces as a plain HTTP error — the same philosophy as the analyst subagent's chat path, where failed chart generation persists nothing either.
Design decisions
InsightResponse): saves the client theresource_urlfollow-up round-trip; the insight remains re-fetchable viaGET /api/insights/{id}and listable viaGET /api/insights.failedrow:502when the analytics pull fails or errors,504when it exceeds the in-request budget (asyncio.timeout(50s), kept below a typical 60s gateway idle timeout). Failures stay observable through structured logs (analysis_failed/analysis_timed_out, severity=high) rather than DB rows nothing reads.persist_insightis again the single write entry point: the session-acceptingadd_insightsplit existed only for the job transaction, so it's folded back.jobs/job_resources(d4f8a2b6c1e3), including theupdated_attrigger + function. This discards existing job rows — they were only ever written by this endpoint's previous incarnations and nothing reads them; the insights they pointed at are untouched. Downgrade recreates the tables and trigger.Breaking API changes
POST /api/analyzereturnsInsightResponseinstead ofJobResponse.GET /api/jobs/{id}is removed. No frontend caller exists in this repo; if the FE poll loop shipped anywhere, it must switch to reading the analyze response directly.Commits
feat: add single-transaction job persistence to the repo layerfeat: run /api/analyze synchronously, persist the job in one transactionrefactor: drop the background-task job lifecycle methodsrefactor: drop the Job/JobResource layer; /api/analyze returns the insight— the removal, per reviewTests
tests/unit/api/services/test_analysis_runner.py(replaces the job-runner tests): insight id return, charts-only insight,thread_idpass-through, upstream failure / exception / timeout each raise and persist nothing, persistence-error propagation.tests/api/test_analyze.py(rewritten): synchronous insight response, insight retrievable viaGET /api/insights/{id}, upstream failure →502with zero rows persisted.tests/api/test_job_repository.pyremoved with the layer.test_aois.pyfailures are local-env only: no PostGIS; CI usespostgis/postgis).pre-commit(ruff + mypy) clean.Non-goals
The long-term scaling concern (many users × slow analytics pulls holding open connections) is unchanged. If a queue lands later, a job/polling contract can be reintroduced then — designed against a real second consumer instead of speculatively.
Stacking
Based on
feat/curated-insights-registry(#762). No file overlap with #763–#766.