Skip to content

Run /api/analyze synchronously: single-transaction job persistence - #779

Open
01painadam wants to merge 4 commits into
feat/curated-insights-registryfrom
feat/synchronous-analyze
Open

Run /api/analyze synchronously: single-transaction job persistence#779
01painadam wants to merge 4 commits into
feat/curated-insights-registryfrom
feat/synchronous-analyze

Conversation

@01painadam

@01painadam 01painadam commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

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 the Job/JobResource abstraction entirely rather than keeping it as a synchronous shell.

The reviewer's observation held up: JobType had exactly one value, /api/analyze was 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/analyze now mirrors the agent/chat path:

POST /api/analyze
  ├─ 1. Validate dataset_id                        (422 if unknown — unchanged)
  ├─ 2. Pull data from analytics API + generate charts    (nothing persisted yet)
  ├─ 3. ONE transaction via persist_insight: InsightOrm + InsightChartOrm
  └─ 4. Return 200 InsightResponse — same shape as GET /api/insights/{id}

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

  • Response is the insight itself (InsightResponse): saves the client the resource_url follow-up round-trip; the insight remains re-fetchable via GET /api/insights/{id} and listable via GET /api/insights.
  • Failure → HTTP error, not a persisted failed row: 502 when the analytics pull fails or errors, 504 when 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_insight is again the single write entry point: the session-accepting add_insight split existed only for the job transaction, so it's folded back.
  • Migration drops jobs/job_resources (d4f8a2b6c1e3), including the updated_at trigger + 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/analyze returns InsightResponse instead of JobResponse.
  • 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

  1. feat: add single-transaction job persistence to the repo layer
  2. feat: run /api/analyze synchronously, persist the job in one transaction
  3. refactor: drop the background-task job lifecycle methods
  4. refactor: drop the Job/JobResource layer; /api/analyze returns the insight — the removal, per review

Tests

  • tests/unit/api/services/test_analysis_runner.py (replaces the job-runner tests): insight id return, charts-only insight, thread_id pass-through, upstream failure / exception / timeout each raise and persist nothing, persistence-error propagation.
  • tests/api/test_analyze.py (rewritten): synchronous insight response, insight retrievable via GET /api/insights/{id}, upstream failure → 502 with zero rows persisted.
  • tests/api/test_job_repository.py removed with the layer.
  • Migration verified both directions against a scratch DB (upgrade drops tables + trigger function; downgrade recreates them).
  • Full unit/api tiers green locally (the 4 pre-existing test_aois.py failures are local-env only: no PostGIS; CI uses postgis/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.

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.
@yellowcap

Copy link
Copy Markdown
Collaborator

Question on the design here rather than the implementation: now that /api/analyze runs synchronously and returns a terminal job in one response, is the Job/JobResource abstraction (JobOrm, JobResourceOrm, JobRepository, the jobs router) still pulling its weight?

A few things that made me question it:

  • JobType has exactly one value (ANALYSIS) and analyze.py is the only caller of JobRepository.create_* — it was introduced in Analyze endpoint #704 as a generic long-running-operation pattern, but a second job type/consumer never materialized.
  • GET /api/jobs/{id} has no list endpoint and (in this repo at least) no frontend caller — its only consumers are its own docstring and this PR's tests.
  • ResourceStatus also has exactly one value (COMPLETED); every job produces exactly one resource pointing at exactly one insight, so the "job → multiple resources" generality has never been exercised either.
  • The one thing JobOrm gives us that InsightOrm doesn't is a persisted failed row. But the analyst subagent's chat path (src/agent/subagents/analyst/tool.py) does the same chart-generation work in-loop and, on failure, just returns an error ToolMessage — nothing is persisted. So we already have two different philosophies for the same failure case, and /api/analyze is the outlier that writes a DB row for it.

Now that the original justification for Job (long-running + pollable) is gone, would it be simpler to drop JobOrm/JobResourceOrm/the jobs router/JobRepository entirely and have /api/analyze mirror the chat path — call persist_insight/add_insight directly, return the insight resource on success, and surface failure as a normal HTTP error rather than a persisted failed row? Happy to be told there's a product reason (e.g. wanting a durable, queryable audit trail of failed analyze calls) that I'm missing — just doesn't seem to be exercised anywhere today.

@yellowcap yellowcap left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.
@01painadam

Copy link
Copy Markdown
Collaborator Author

Agreed on all points — there's no product requirement for a durable audit trail of failed analyze calls, so the failed-row behaviour was incidental, not intentional. Done in 3fbb1f8:

  • JobOrm/JobResourceOrm, JobRepository, the jobs router and the Job schemas are gone; migration d4f8a2b6c1e3 drops the jobs/job_resources tables (and the updated_at trigger function). Existing job rows are discarded — nothing reads them, and the insights they pointed at are untouched.
  • POST /api/analyze now mirrors the chat path exactly as you suggested: success goes through persist_insight (the add_insight split is folded back too, since the job transaction was its only reason to exist) and returns the insight itself — same shape as GET /api/insights/{id}, saving the resource_url round-trip.
  • Failure persists nothing and surfaces as 502 (upstream failure) or 504 (in-request timeout); observability moves to the structured logs (analysis_failed/analysis_timed_out, severity=high), which is where the chat path's failures already live.

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.

@01painadam
01painadam requested a review from yellowcap July 28, 2026 18:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants